Skip to content
Merged
2 changes: 1 addition & 1 deletion Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ private struct CompactChatMessageBubble: View {
}

private func userTextBubble(_ text: String) -> some View {
MarkdownContentView(text: text, style: .rxCodeChatUser)
MarkdownContentView(text: text, style: .rxCodeChatUser, expandsHorizontally: false)
.bubbleStyle(.user)
.frame(maxWidth: 500, alignment: .trailing)
}
Expand Down
4 changes: 4 additions & 0 deletions Packages/Sources/RxCodeChatKit/MarkdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public struct MarkdownContentView: View {
let baseURL: URL?
let style: MarkdownStyle
let fadeNewText: Bool
let expandsHorizontally: Bool
let onOpenLink: MarkdownView.LinkHandler?

public init(
Expand All @@ -60,6 +61,7 @@ public struct MarkdownContentView: View {
baseURL: URL? = nil,
style: MarkdownStyle = .rxCodeChat,
fadeNewText: Bool = false,
expandsHorizontally: Bool = true,
onOpenLink: MarkdownView.LinkHandler? = nil
) {
self.text = text
Expand All @@ -68,6 +70,7 @@ public struct MarkdownContentView: View {
self.baseURL = baseURL
self.style = style
self.fadeNewText = fadeNewText
self.expandsHorizontally = expandsHorizontally
self.onOpenLink = onOpenLink
}

Expand All @@ -79,6 +82,7 @@ public struct MarkdownContentView: View {
baseURL: baseURL,
style: style,
fadeNewText: fadeNewText,
expandsHorizontally: expandsHorizontally,
onOpenLink: onOpenLink
)
.tint(ClaudeTheme.accent)
Expand Down
3 changes: 2 additions & 1 deletion Packages/Sources/RxCodeChatKit/MessageBubble.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@ struct MessageBubble: View {
let collapsed = isLong && !isLongTextExpanded
MarkdownContentView(
text: markdownUserText(displayText),
style: .rxCodeChatUser
style: .rxCodeChatUser,
expandsHorizontally: false
) { url in
// Intercept the synthetic `rxcode-image://<index>` link emitted
// by markdownUserText and open the matching image in the preview
Expand Down
25 changes: 25 additions & 0 deletions Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,16 @@
}
}
},
"Code review will start in %lld %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Code review will start in %1$lld %2$@"
}
}
}
},
"Collapse" : {
"localizations" : {
"en" : {
Expand Down Expand Up @@ -4616,6 +4626,12 @@
}
}
}
},
"Review cancelled" : {

},
"Review started" : {

},
"Rewind to a previous point" : {
"localizations" : {
Expand Down Expand Up @@ -5384,6 +5400,12 @@
}
}
}
},
"Start it now" : {

},
"Stop Review" : {

},
"Subagent" : {
"localizations" : {
Expand Down Expand Up @@ -6090,6 +6112,9 @@
}
}
}
},
"Waiting to start review" : {

},
"What should we build in %@?" : {
"localizations" : {
Expand Down
118 changes: 118 additions & 0 deletions Packages/Sources/RxCodeChatKit/ReviewCountdownCardView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import RxCodeCore
import SwiftUI

/// Interactive card shown while a code review is debounced before it starts:
///
/// Code review will start in 47 seconds
/// [ Stop Review ] [ Start it now ]
///
/// Rendered by `ToolResultView` whenever it sees a `ToolCall` named
/// `ReviewCountdownCard.toolName`. While counting (`toolCall.result == nil`) it
/// ticks a live countdown and shows the two buttons; once the review is started
/// or cancelled the hook fills in `toolCall.result` and this collapses to a
/// static one-line summary with a status badge.
///
/// The buttons route through `windowState.reviewCountdownHandler`. The desktop
/// installs that handler; mobile does not, so the card is read-only there (it
/// still shows the live countdown, just without the buttons).
struct ReviewCountdownCardView: View {
let toolCall: ToolCall

@Environment(WindowState.self) private var windowState
@State private var now: Date = Date()

private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

private var parentSessionKey: String {
toolCall.input[ReviewCountdownCard.parentSessionKey]?.stringValue ?? ""
}

private var startAt: Date? {
guard let seconds = toolCall.input[ReviewCountdownCard.startAtKey]?.numberValue else { return nil }
return Date(timeIntervalSince1970: seconds)
}

/// Whole seconds left before the review auto-starts (never negative).
private var remainingSeconds: Int {
guard let startAt else { return 0 }
return max(0, Int(startAt.timeIntervalSince(now).rounded(.up)))
}

/// Still counting down until the hook fills in a result.
private var isCounting: Bool { toolCall.result == nil }

var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "checklist")
.font(.system(size: ClaudeTheme.messageSize(13), weight: .medium))
.foregroundStyle(ClaudeTheme.accent)
.frame(width: 16, height: 16)

headline
.font(.system(size: ClaudeTheme.messageSize(13), weight: .medium))
.foregroundStyle(ClaudeTheme.textPrimary)

Spacer()

statusBadge
}

// Buttons are desktop-only (mobile renders the countdown read-only).
// Gated at compile time, not on the handler being non-nil, so they
// reliably appear during the countdown regardless of wiring timing.
#if os(macOS)
if isCounting {
HStack(spacing: 8) {
Button {
windowState.reviewCountdownHandler?(parentSessionKey, .stop)
} label: {
Text("Stop Review")
.font(.system(size: ClaudeTheme.messageSize(12), weight: .medium))
}
.buttonStyle(.bordered)

Button {
windowState.reviewCountdownHandler?(parentSessionKey, .startNow)
} label: {
Text("Start it now")
.font(.system(size: ClaudeTheme.messageSize(12), weight: .medium))
}
.buttonStyle(.borderedProminent)
}
}
#endif
}
.bubbleStyle(.tool)
.padding(.vertical, 6)
.onReceive(timer) { now = $0 }
}

private var headline: Text {
if isCounting {
let unit = remainingSeconds == 1 ? "second" : "seconds"
return Text("Code review will start in \(remainingSeconds) \(unit)")
}
// Finalized: the hook wrote a short summary into the result.
return Text(verbatim: toolCall.result ?? "")
}

@ViewBuilder
private var statusBadge: some View {
if isCounting {
ProgressView()
.controlSize(.mini)
.accessibilityLabel("Waiting to start review")
} else if toolCall.isError {
Image(systemName: "minus.circle.fill")
.foregroundStyle(ClaudeTheme.textTertiary)
.font(.caption)
.accessibilityLabel("Review cancelled")
} else {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(ClaudeTheme.statusSuccess)
.font(.caption)
.accessibilityLabel("Review started")
}
}
}
9 changes: 9 additions & 0 deletions Packages/Sources/RxCodeChatKit/ToolResultView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ struct ToolResultView: View {
}

var body: some View {
if toolCall.name == ReviewCountdownCard.toolName {
// Interactive "review will start in N seconds" card (Stop / Start now).
ReviewCountdownCardView(toolCall: toolCall)
} else {
standardBody
}
}

private var standardBody: some View {
Group {
if isCardTool {
cardBody
Expand Down
12 changes: 8 additions & 4 deletions Packages/Sources/RxCodeCore/Hooks/Hook.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ public protocol Hook: AnyObject {
var isEnabled: Bool { get }

func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome
func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [HookMenuItem]
func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [HookMenuItem]
/// Serializable menu items for a thread's context menu. The desktop renders
/// these `MenuItem`s natively (a `MenuItem` is a `View`); mobile fetches the
/// same items over the relay as JSON and renders the identical type.
func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem]
/// Serializable menu items for a project's context menu (see above).
func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem]
func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome
func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome
func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome
Expand All @@ -40,8 +44,8 @@ public extension Hook {
var isEnabled: Bool { true }

func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [HookMenuItem] { [] }
func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [HookMenuItem] { [] }
func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { [] }
func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem] { [] }
func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome { .ignored }
func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome { .ignored }
func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { .ignored }
Expand Down
86 changes: 81 additions & 5 deletions Packages/Sources/RxCodeCore/Hooks/HookController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ public protocol HookController: AnyObject {
func changedFilePaths(sessionId: String) -> [String]
/// The first user prompt text of a thread, if any.
func firstUserPrompt(sessionId: String) -> String?
/// A readable transcript of a thread's assistant activity (text + tool-call
/// summaries), used to fold a review thread's full content into a card on the
/// parent thread. Excludes the injected instruction prompt.
/// A readable transcript of a thread's assistant *text*, used to fold a review
/// thread's content into a card on the parent thread. Tool calls and the
/// injected instruction prompt are excluded so the result shown back on the
/// parent thread is prose, not a list of tool names.
func threadTranscript(sessionId: String) -> String
/// Spawn a new linked thread (e.g. `[Code Review]`) that runs no hooks, and
/// wait for its first response. Returns the resolved thread id + assistant
Expand All @@ -67,8 +68,46 @@ public protocol HookController: AnyObject {
prompt: String,
timeoutSeconds: TimeInterval
) async -> HookLinkedThreadResult?
/// Send a follow-up prompt into an existing thread without waiting.
func sendThreadMessage(sessionId: String, prompt: String)
/// Send a follow-up prompt into an existing thread without waiting. Pass
/// `setupKind` to mark the session for once-per-session loop prevention; the
/// marker is recorded *after* the message is appended, so the resulting turn
/// doesn't clear its own marker.
func sendThreadMessage(sessionId: String, prompt: String, setupKind: String?)
/// Ask a lightweight model whether `condition` holds given the turn's final
/// assistant text. Returns `true` only on an explicit YES, `false` on NO, and
/// `nil` when no verdict could be obtained. `model` is a provider-qualified
/// override (empty/`nil` ⇒ the configured summarization model).
func evaluateCondition(condition: String, lastAssistantText: String, model: String?, sessionId: String) async -> Bool?
// MARK: Review debounce / cancellation

/// Debounce applied before an automatic code review starts, in seconds. The
/// hook shows a countdown card for this long so a new follow-up message can
/// cancel the review before it runs.
var reviewCountdownSeconds: TimeInterval { get }
/// Suspend until the review countdown for `parentSessionKey` elapses, the
/// user taps "Start it now", or it is cancelled (Stop button / new message).
/// Returns `true` to proceed with the review, `false` if it was cancelled.
func awaitReviewGate(parentSessionKey: String, delaySeconds: TimeInterval) async -> Bool
/// Mark/clear that the review thread for `parentSessionKey` is running, so a
/// later cancellation can stop the in-flight review thread and the context
/// menu can show "Stop Code Review".
func markReviewRunning(parentSessionKey: String)
func clearReviewRunning(parentSessionKey: String)
/// One-shot: whether the running review for `parentSessionKey` was stopped
/// (by a new message or the Stop action) rather than finishing on its own.
func reviewWasStopped(parentSessionKey: String) -> Bool
/// Why the most recent cancel happened for the thread, so the hook can word
/// the finalized card correctly (e.g. an explicit Stop vs a new message).
func reviewCancelReason(parentSessionKey: String) -> ReviewCancelReason?
/// Whether a review is pending (counting down) or running for the thread —
/// gates the "Stop Code Review" context-menu item. Synchronous because the
/// menu is built synchronously.
func threadHasOngoingReview(sessionId: String) -> Bool
/// Whether the thread already has a newer turn in progress (streaming or a
/// freshly-appended, not-yet-answered user message). Lets the review hook
/// skip reviewing a turn the user has already superseded with a follow-up.
func threadHasNewerActivity(sessionId: String) -> Bool

/// Record the latest review verdict for a session (shared between the
/// code-review and commit hooks within one stop cycle).
func setReviewPassed(_ passed: Bool, sessionId: String)
Expand All @@ -79,6 +118,13 @@ public protocol HookController: AnyObject {
func reviewRound(sessionId: String) -> Int
/// Set the failed-review round counter for a session.
func setReviewRound(_ round: Int, sessionId: String)
/// Feed a failing code review's feedback back into the reviewed thread as a
/// bounded auto-continue fix turn (rendered as an auto-continue card, not a
/// user message). The thread runs the Code Review hook again when the fix
/// turn finishes, so the change is re-reviewed. Returns the 1-based attempt
/// number started, or `nil` if the per-session fix-round cap was already
/// reached (the caller then surfaces a "stopped" card instead of looping).
func repromptThreadAfterReviewFailure(feedback: String, project: Project, sessionKey: String) -> Int?
/// First-sentence fallback body for a response-complete notification.
func responseNotificationFallback(from responseText: String) -> String
/// Optionally generate an AI summary of the response for a notification body.
Expand Down Expand Up @@ -201,6 +247,25 @@ public protocol HookController: AnyObject {
func projectHasSecrets(_ project: Project) -> Bool
func projectHasDocs(_ project: Project) -> Bool
func projectHasReleaseWorkflow(_ project: Project) -> Bool
/// Whether the project's linked repo is already configured for CI
/// auto-updates (a "watched repository"). Hides "Set Up CI Update" when it's
/// already set up; also false when the project has no GitHub repo or the
/// status cache hasn't been populated yet (fail-open: the item shows).
func projectHasCIUpdates(_ project: Project) -> Bool

/// Whether the project's working tree has uncommitted changes (gates the
/// "Commit All Changes" item). Defaults to visible when status is unknown.
func projectHasUncommittedChanges(_ project: Project) -> Bool
/// Whether the project already has an open pull request for `branch` (or the
/// project's current branch when `branch` is nil). Hides "Create Pull Request"
/// when one exists; also false when the project has no GitHub repo.
func projectHasOpenPullRequest(_ project: Project, branch: String?) -> Bool
/// Whether CI is currently failing for `branch` (or the project's current
/// branch when `branch` is nil). Gates the "Fix Failing CI" item; false when
/// the project has no GitHub repo or no CI status is known yet.
func projectCIIsFailing(_ project: Project, branch: String?) -> Bool
/// Whether the thread recorded any file edits (gates "Commit Files").
func threadHasFileChanges(sessionId: String) -> Bool

/// Centralized presentation actions used by hook-supplied context menus.
func requestSecretsSetup(project: Project)
Expand All @@ -209,6 +274,7 @@ public protocol HookController: AnyObject {
func requestDocsSearch(project: Project)
func requestReleaseSetup(project: Project)
func requestReleaseCreate(project: Project)
func requestCISetup(project: Project)

// MARK: Setup-session tracking

Expand Down Expand Up @@ -236,6 +302,11 @@ public enum HookSetupKind {
/// Marks the follow-up turn the Commit & Push hook itself triggered, so the
/// hook short-circuits on that turn instead of looping forever.
public static let commitPush = "commitPush"
/// Marks a thread that the Send Message hook has already fired for this
/// session. Unlike `commitPush` it is *not* self-cleared; it persists until
/// the user sends a new message (reset in `AppState.sendPrompt`), giving the
/// hook once-per-session semantics.
public static let sendMessage = "sendMessage"
}

/// Result of spawning a linked thread via `spawnLinkedThread`.
Expand Down Expand Up @@ -286,6 +357,11 @@ public struct HookBannerItem: Identifiable {
}

public extension HookController {
/// Convenience: send a follow-up prompt without marking a setup session.
func sendThreadMessage(sessionId: String, prompt: String) {
sendThreadMessage(sessionId: sessionId, prompt: prompt, setupKind: nil)
}

/// Ergonomic `@ViewBuilder` form mirroring the requested call site:
/// `controller.showBanner(in: .newProject, position: .aboveInputBox, id: …) { MyBanner() }`.
func showBanner<Content: View>(
Expand Down
Loading
Loading