diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift index 9e900edd..9f9aabbe 100644 --- a/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift @@ -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) } diff --git a/Packages/Sources/RxCodeChatKit/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index 957f6362..f30fb948 100644 --- a/Packages/Sources/RxCodeChatKit/MarkdownView.swift +++ b/Packages/Sources/RxCodeChatKit/MarkdownView.swift @@ -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( @@ -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 @@ -68,6 +70,7 @@ public struct MarkdownContentView: View { self.baseURL = baseURL self.style = style self.fadeNewText = fadeNewText + self.expandsHorizontally = expandsHorizontally self.onOpenLink = onOpenLink } @@ -79,6 +82,7 @@ public struct MarkdownContentView: View { baseURL: baseURL, style: style, fadeNewText: fadeNewText, + expandsHorizontally: expandsHorizontally, onOpenLink: onOpenLink ) .tint(ClaudeTheme.accent) diff --git a/Packages/Sources/RxCodeChatKit/MessageBubble.swift b/Packages/Sources/RxCodeChatKit/MessageBubble.swift index fa78fa42..5b4e9e8e 100644 --- a/Packages/Sources/RxCodeChatKit/MessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/MessageBubble.swift @@ -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://` link emitted // by markdownUserText and open the matching image in the preview diff --git a/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings b/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings index 398e4f0b..9a46837e 100644 --- a/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings +++ b/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings @@ -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" : { @@ -4616,6 +4626,12 @@ } } } + }, + "Review cancelled" : { + + }, + "Review started" : { + }, "Rewind to a previous point" : { "localizations" : { @@ -5384,6 +5400,12 @@ } } } + }, + "Start it now" : { + + }, + "Stop Review" : { + }, "Subagent" : { "localizations" : { @@ -6090,6 +6112,9 @@ } } } + }, + "Waiting to start review" : { + }, "What should we build in %@?" : { "localizations" : { diff --git a/Packages/Sources/RxCodeChatKit/ReviewCountdownCardView.swift b/Packages/Sources/RxCodeChatKit/ReviewCountdownCardView.swift new file mode 100644 index 00000000..8d9b3cf8 --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/ReviewCountdownCardView.swift @@ -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") + } + } +} diff --git a/Packages/Sources/RxCodeChatKit/ToolResultView.swift b/Packages/Sources/RxCodeChatKit/ToolResultView.swift index 1cfdbf46..bdbbb4f7 100644 --- a/Packages/Sources/RxCodeChatKit/ToolResultView.swift +++ b/Packages/Sources/RxCodeChatKit/ToolResultView.swift @@ -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 diff --git a/Packages/Sources/RxCodeCore/Hooks/Hook.swift b/Packages/Sources/RxCodeCore/Hooks/Hook.swift index d6e97abd..4bfedaad 100644 --- a/Packages/Sources/RxCodeCore/Hooks/Hook.swift +++ b/Packages/Sources/RxCodeCore/Hooks/Hook.swift @@ -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 @@ -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 } diff --git a/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift index 229cb2b3..7e9b0897 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookController.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookController.swift @@ -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 @@ -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) @@ -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. @@ -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) @@ -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 @@ -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`. @@ -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( diff --git a/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift index b73294ab..7b01d098 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift @@ -62,9 +62,15 @@ public struct ThreadContextMenuPayload: Codable, Sendable { public struct ProjectContextMenuPayload: Codable, Sendable { public let project: Project + /// When set, the menu is scoped to a specific branch (e.g. a briefing card, + /// which represents one branch). Branch-scoped actions — Code Review, Create + /// Pull Request — target this branch instead of the project's current branch. + /// `nil` for the project list / sidebar, which act on the current branch. + public let branch: String? - public init(project: Project) { + public init(project: Project, branch: String? = nil) { self.project = project + self.branch = branch } } diff --git a/Packages/Sources/RxCodeCore/Menu/MenuDeepLink.swift b/Packages/Sources/RxCodeCore/Menu/MenuDeepLink.swift new file mode 100644 index 00000000..544c40a1 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Menu/MenuDeepLink.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Builds and parses the `rxcode://menu/?projectId=…&sessionId=…` deep +/// links carried by `MenuAction.deepLink`. +/// +/// Sheet- and setup-style menu items (secrets, docs, release, CI) present +/// platform-local UI rather than running work on the desktop, so they travel as +/// deep links: the desktop maps the link to its existing request methods / +/// sheets, and mobile maps the same link to its own sheets / setup chat. The +/// host `menu` keeps these distinct from the app's other `rxcode://` routes +/// (`secrets/add`, `ci/add`, …) which remain unchanged. +public enum MenuDeepLink { + public static let scheme = "rxcode" + public static let host = "menu" + + // Action identifiers (the URL path component). + public static let secretsSetup = "secrets-setup" + public static let secretsDownload = "secrets-download" + public static let docsSetup = "docs-setup" + public static let docsSearch = "docs-search" + public static let releaseSetup = "release-setup" + public static let releaseCreate = "release-create" + public static let ciSetup = "ci-setup" + + /// Decoded form of a menu deep link. + public struct Parsed: Equatable, Sendable { + public let action: String + public let projectId: UUID? + public let sessionId: String? + + public init(action: String, projectId: UUID?, sessionId: String?) { + self.action = action + self.projectId = projectId + self.sessionId = sessionId + } + } + + /// Build a menu deep link URL for an `action`, addressing a project and/or + /// thread by id. + public static func url(action: String, projectId: UUID? = nil, sessionId: String? = nil) -> URL { + var components = URLComponents() + components.scheme = scheme + components.host = host + components.path = "/\(action)" + var items: [URLQueryItem] = [] + if let projectId { items.append(URLQueryItem(name: "projectId", value: projectId.uuidString)) } + if let sessionId { items.append(URLQueryItem(name: "sessionId", value: sessionId)) } + components.queryItems = items.isEmpty ? nil : items + // Inputs are always valid here (fixed scheme/host, UUID/string params), so + // the URL is guaranteed to construct. + return components.url! + } + + /// Parse a URL into a menu action, or `nil` if it isn't a `rxcode://menu/…` + /// deep link. + public static func parse(_ url: URL) -> Parsed? { + guard url.scheme == scheme, url.host == host, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + + let action = components.path.hasPrefix("/") + ? String(components.path.dropFirst()) + : components.path + guard !action.isEmpty else { return nil } + + var query: [String: String] = [:] + for item in components.queryItems ?? [] { + query[item.name] = item.value + } + return Parsed( + action: action, + projectId: query["projectId"].flatMap(UUID.init(uuidString:)), + sessionId: query["sessionId"] + ) + } +} diff --git a/Packages/Sources/RxCodeCore/Menu/MenuItem.swift b/Packages/Sources/RxCodeCore/Menu/MenuItem.swift new file mode 100644 index 00000000..6c35dca9 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Menu/MenuItem.swift @@ -0,0 +1,249 @@ +import Foundation +import SwiftUI + +// MARK: - Serializable, cross-platform menu + +/// A single menu entry that is BOTH a SwiftUI `View` and `Codable`. +/// +/// This is the one menu vocabulary shared by desktop and mobile. The desktop +/// builds `[MenuItem]` from hooks and renders them natively (a `MenuItem` is a +/// `View`). Mobile asks the desktop for the same `[MenuItem]`, which crosses the +/// E2E relay as JSON, and renders the *identical* type on-device. Because the +/// item carries a serializable `MenuAction` (never a closure), the same value is +/// safe to encode and ship. +/// +/// Tapping a leaf item routes its `action` to the `menuActionHandler` injected +/// through the environment, so each platform decides what a command means: +/// desktop dispatches it locally, mobile sends it back over the relay, and a +/// `.deepLink` opens platform-local UI (a sheet) on whichever side renders it. +public struct MenuItem: View, Codable, Identifiable, Hashable, Sendable { + /// Whether this row is a tappable item or a visual separator. + public enum Kind: String, Codable, Sendable { + case item + case separator + } + + public let id: String + public let kind: Kind + /// Already-localized, display-ready title. The desktop is the only side that + /// builds menus, so titles are resolved there (`LocalizedStringResource` is + /// not cleanly `Codable`) and shipped as plain strings. + public let title: String + public let systemImage: String? + public let role: MenuItemRole? + public let isDisabled: Bool + /// What happens on tap. `nil` for separators and for submenu parents. + public let action: MenuAction? + /// Non-nil when this item is a submenu; rendered as a nested `Menu`. + public let children: [MenuItem]? + + public init( + id: String, + kind: Kind = .item, + title: String = "", + systemImage: String? = nil, + role: MenuItemRole? = nil, + isDisabled: Bool = false, + action: MenuAction? = nil, + children: [MenuItem]? = nil + ) { + self.id = id + self.kind = kind + self.title = title + self.systemImage = systemImage + self.role = role + self.isDisabled = isDisabled + self.action = action + self.children = children + } + + /// A non-interactive divider between sections. + public static func separator(id: String) -> MenuItem { + MenuItem(id: id, kind: .separator) + } + + // MARK: View + + public var body: some View { + MenuItemRow(item: self) + } +} + +/// `ButtonRole` isn't `Codable`; mirror just the roles a menu needs. +public enum MenuItemRole: String, Codable, Sendable, Hashable { + case destructive +} + +/// What a tapped menu item does. Either run a named command (the desktop is the +/// source of truth and performs the work) or open a deep link that presents +/// platform-local UI such as a sheet. +public enum MenuAction: Codable, Sendable, Hashable { + case command(MenuActionCommand) + case deepLink(URL) +} + +/// A serializable description of work to perform. `kind` selects the operation; +/// the optional fields carry its parameters. Kept flat (rather than per-command +/// structs) so one `Codable` type round-trips every command and the dispatcher +/// is a single switch. +public struct MenuActionCommand: Codable, Sendable, Hashable { + public enum Kind: String, Codable, Sendable { + // Project-scoped — real work the desktop performs. + case projectCodeReview // review the project's current branch + case projectCommitAll // commit all uncommitted changes + case projectCreatePullRequest // push branch + open a PR + case projectFixCI // spawn a thread to fix failing CI + + // Thread-scoped. + case threadCodeReview // review one thread's changes + case threadCommitFiles // commit only this thread's files + case threadStopCodeReview // stop an in-flight code review for the thread + } + + public let kind: Kind + public let projectId: UUID? + public let sessionId: String? + public let branch: String? + /// Whether dispatching this command performs long-running asynchronous work + /// (e.g. pushing a branch and opening a pull request). When `true`, the side + /// that runs the command shows a blocking loading dialog and waits for it to + /// finish before dismissing — desktop drives `HookController.beginProgress`, + /// mobile shows its action loading dialog. Serialized so the desktop's hook + /// can declare the flag once and both platforms honor it. + public let isAsync: Bool + + public init( + kind: Kind, + projectId: UUID? = nil, + sessionId: String? = nil, + branch: String? = nil, + isAsync: Bool = false + ) { + self.kind = kind + self.projectId = projectId + self.sessionId = sessionId + self.branch = branch + self.isAsync = isAsync + } + + /// Status line shown in the loading dialog while an `isAsync` command runs. + public var progressStatus: LocalizedStringKey { + switch kind { + case .projectCreatePullRequest: return "Creating pull request…" + default: return "Working…" + } + } + + // Custom decoding so a menu serialized by a peer that predates `isAsync` + // (version skew between desktop and mobile) still decodes, defaulting to a + // synchronous command. Encoding stays synthesized. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = try container.decode(Kind.self, forKey: .kind) + projectId = try container.decodeIfPresent(UUID.self, forKey: .projectId) + sessionId = try container.decodeIfPresent(String.self, forKey: .sessionId) + branch = try container.decodeIfPresent(String.self, forKey: .branch) + isAsync = try container.decodeIfPresent(Bool.self, forKey: .isAsync) ?? false + } +} + +// MARK: - Action handler (environment) + +/// Platform-specific sink for menu taps. Injected via `.menuActionHandler(...)` +/// so the very same `MenuItem` view dispatches differently on each side: +/// desktop runs the command locally, mobile relays it, and either opens deep +/// links with its own URL handling. +public struct MenuActionHandler: Sendable { + public let perform: @MainActor (MenuAction) -> Void + + public init(perform: @escaping @MainActor (MenuAction) -> Void) { + self.perform = perform + } + + /// A no-op handler (the environment default). + public static let none = MenuActionHandler { _ in } +} + +private struct MenuActionHandlerKey: EnvironmentKey { + static let defaultValue = MenuActionHandler.none +} + +public extension EnvironmentValues { + /// The sink that receives a `MenuAction` when a `MenuItem` is tapped. + var menuActionHandler: MenuActionHandler { + get { self[MenuActionHandlerKey.self] } + set { self[MenuActionHandlerKey.self] = newValue } + } +} + +public extension View { + /// Install the handler that `MenuItem` taps route through. + func menuActionHandler(_ handler: MenuActionHandler) -> some View { + environment(\.menuActionHandler, handler) + } + + /// Convenience: install a handler from a bare closure. + func menuActionHandler(_ perform: @escaping @MainActor (MenuAction) -> Void) -> some View { + environment(\.menuActionHandler, MenuActionHandler(perform: perform)) + } +} + +// MARK: - Rendering + +/// Renders a single `MenuItem`, reading the active `menuActionHandler` from the +/// environment. Kept private so `MenuItem` stays a clean `Codable` value type +/// (no stored `@Environment`); the environment lookup lives here instead. +private struct MenuItemRow: View { + let item: MenuItem + @Environment(\.menuActionHandler) private var handler + + var body: some View { + switch item.kind { + case .separator: + Divider() + case .item: + if let children = item.children, !children.isEmpty { + Menu { + ForEach(children) { child in child } + } label: { + label + } + } else { + Button(role: buttonRole) { + if let action = item.action { handler.perform(action) } + } label: { + label + } + .disabled(item.isDisabled) + } + } + } + + private var buttonRole: ButtonRole? { + item.role == .destructive ? .destructive : nil + } + + @ViewBuilder + private var label: some View { + if let systemImage = item.systemImage { + Label(item.title, systemImage: systemImage) + } else { + Text(item.title) + } + } +} + +/// Renders a list of `MenuItem`s as menu content (buttons, submenus, and +/// dividers). Drop this inside a `.contextMenu { }` or `Menu { }` builder on +/// either platform. +public struct MenuItemsView: View { + public let items: [MenuItem] + + public init(_ items: [MenuItem]) { + self.items = items + } + + public var body: some View { + ForEach(items) { item in item } + } +} diff --git a/Packages/Sources/RxCodeCore/Models/ChatMessage.swift b/Packages/Sources/RxCodeCore/Models/ChatMessage.swift index e0edbde5..c0748c17 100644 --- a/Packages/Sources/RxCodeCore/Models/ChatMessage.swift +++ b/Packages/Sources/RxCodeCore/Models/ChatMessage.swift @@ -243,7 +243,11 @@ public struct ToolCall: Identifiable, Codable, Sendable, Equatable { /// either because the result would be empty by design, or because the UI /// needs to render them before the user/CLI produces a result. public static let keepAlwaysNames: Set = [ - "agent", "edit", "multiedit", "multi_edit", "write", "askuserquestion", "exitplanmode", "exit_plan_mode" + "agent", "edit", "multiedit", "multi_edit", "write", "askuserquestion", "exitplanmode", "exit_plan_mode", + // The review countdown card renders its live timer (and Stop / Start-now + // buttons) while `result == nil`; without this the render filter would + // drop the whole card until the countdown finished and a result landed. + ReviewCountdownCard.toolName.lowercased(), ] public var isKeepAlways: Bool { diff --git a/Packages/Sources/RxCodeCore/Models/ChatThread.swift b/Packages/Sources/RxCodeCore/Models/ChatThread.swift index afec8d88..5f8adc34 100644 --- a/Packages/Sources/RxCodeCore/Models/ChatThread.swift +++ b/Packages/Sources/RxCodeCore/Models/ChatThread.swift @@ -30,6 +30,10 @@ public final class ChatThread { public var threadLabel: String? = nil /// When true, lifecycle hooks are skipped for this thread. public var skipHooks: Bool = false + /// Latest code-review verdict for this thread (set by `CodeReviewHook`): + /// `true` = passed, `false` = found issues, `nil` = never reviewed. Persisted + /// so the sidebar review dot survives an app reload. Defaulted for clean migration. + public var reviewPassed: Bool? = nil public init( id: String, @@ -50,7 +54,8 @@ public final class ChatThread { archivedAt: Date? = nil, parentThreadId: String? = nil, threadLabel: String? = nil, - skipHooks: Bool = false + skipHooks: Bool = false, + reviewPassed: Bool? = nil ) { self.id = id self.projectId = projectId @@ -71,6 +76,7 @@ public final class ChatThread { self.parentThreadId = parentThreadId self.threadLabel = threadLabel self.skipHooks = skipHooks + self.reviewPassed = reviewPassed } } diff --git a/Packages/Sources/RxCodeCore/Models/HookProfile.swift b/Packages/Sources/RxCodeCore/Models/HookProfile.swift index 1bd61217..2deba2b7 100644 --- a/Packages/Sources/RxCodeCore/Models/HookProfile.swift +++ b/Packages/Sources/RxCodeCore/Models/HookProfile.swift @@ -34,12 +34,18 @@ public enum HookAction: String, Codable, Sendable, CaseIterable, Hashable { /// Instruct the agent to commit the changed files and push. Pairs with /// `.afterSessionStop`. case commitPush + /// Send a fixed message to the assistant once the session ends — into the + /// same thread or a new linked thread — optionally gated on a model-evaluated + /// condition. Fires once per session (reset when the user sends a new + /// message). Pairs with `.afterSessionStop`. + case sendMessage public var displayName: String { switch self { case .command: return "Command" case .codeReview: return "Code Review" case .commitPush: return "Commit & Push" + case .sendMessage: return "Send Message" } } @@ -50,10 +56,57 @@ public enum HookAction: String, Codable, Sendable, CaseIterable, Hashable { case .command: return nil case .codeReview: return .afterSessionStop case .commitPush: return .afterSessionStop + case .sendMessage: return .afterSessionStop } } } +/// Where a `.sendMessage` hook delivers its message. +public enum SendMessageTarget: String, Codable, Sendable, CaseIterable, Hashable { + /// Inject the message as a follow-up turn in the same thread. + case sameThread + /// Spawn a new linked child thread (runs no hooks) and send the message there. + case newThread +} + +/// Per-hook configuration for the `.sendMessage` action. +public struct SendMessageConfig: Codable, Sendable, Hashable { + /// The fixed text sent to the assistant when the hook fires. + public var message: String + /// Same-thread follow-up vs. a new linked thread. + public var target: SendMessageTarget + /// Optional label for the spawned thread (only used when `target == .newThread`). + public var newThreadLabel: String? + /// Provider-qualified model key for the spawned thread (`:`). + /// Empty/`nil` ⇒ inherit the parent thread's model. Only used for `.newThread`. + public var newThreadModel: String? + /// Gate the send on a model-evaluated condition. + public var conditionEnabled: Bool + /// Natural-language condition the model judges (yes ⇒ send). + public var condition: String? + /// Provider-qualified model key used to evaluate the condition. Empty/`nil` ⇒ + /// the app's configured summarization model. + public var conditionModel: String? + + public init( + message: String = "", + target: SendMessageTarget = .sameThread, + newThreadLabel: String? = nil, + newThreadModel: String? = nil, + conditionEnabled: Bool = false, + condition: String? = nil, + conditionModel: String? = nil + ) { + self.message = message + self.target = target + self.newThreadLabel = newThreadLabel + self.newThreadModel = newThreadModel + self.conditionEnabled = conditionEnabled + self.condition = condition + self.conditionModel = conditionModel + } +} + /// Per-hook configuration for the `.codeReview` action. public struct CodeReviewConfig: Codable, Sendable, Hashable { /// Provider-qualified model key for the review thread (`:`). @@ -94,6 +147,8 @@ public struct HookProfile: Identifiable, Codable, Sendable, Hashable { public var package: PackageRunConfig? /// Populated when `action == .codeReview`. Optional for backward-compatible decode. public var codeReview: CodeReviewConfig? + /// Populated when `action == .sendMessage`. Optional for backward-compatible decode. + public var sendMessage: SendMessageConfig? public var createdAt: Date public var updatedAt: Date @@ -110,6 +165,7 @@ public struct HookProfile: Identifiable, Codable, Sendable, Hashable { make: MakeRunConfig? = nil, package: PackageRunConfig? = nil, codeReview: CodeReviewConfig? = nil, + sendMessage: SendMessageConfig? = nil, createdAt: Date = Date(), updatedAt: Date = Date() ) { @@ -125,12 +181,13 @@ public struct HookProfile: Identifiable, Codable, Sendable, Hashable { self.make = make self.package = package self.codeReview = codeReview + self.sendMessage = sendMessage self.createdAt = createdAt self.updatedAt = updatedAt } private enum CodingKeys: String, CodingKey { - case id, projectId, name, enabled, trigger, action, type, bash, xcode, make, package, codeReview, createdAt, updatedAt + case id, projectId, name, enabled, trigger, action, type, bash, xcode, make, package, codeReview, sendMessage, createdAt, updatedAt } public init(from decoder: Decoder) throws { @@ -147,6 +204,7 @@ public struct HookProfile: Identifiable, Codable, Sendable, Hashable { make = try container.decodeIfPresent(MakeRunConfig.self, forKey: .make) package = try container.decodeIfPresent(PackageRunConfig.self, forKey: .package) codeReview = try container.decodeIfPresent(CodeReviewConfig.self, forKey: .codeReview) + sendMessage = try container.decodeIfPresent(SendMessageConfig.self, forKey: .sendMessage) createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? Date() } diff --git a/Packages/Sources/RxCodeCore/Models/ReviewCountdownCard.swift b/Packages/Sources/RxCodeCore/Models/ReviewCountdownCard.swift new file mode 100644 index 00000000..d3832dc2 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/ReviewCountdownCard.swift @@ -0,0 +1,42 @@ +import Foundation + +/// The serializable contract for the interactive "code review will start in +/// N seconds" countdown card. The Code Review hook inserts a `ToolCall` named +/// `ReviewCountdownCard.toolName` carrying these input keys; `ToolResultView` +/// special-cases that name to render `ReviewCountdownCardView` (a live timer +/// with Stop / Start-now buttons) instead of an ordinary tool card. +/// +/// The card is a plain `ToolCall`, so it syncs to paired mobile devices for +/// free — mobile shows the live countdown read-only (the buttons only appear +/// when a `WindowState.reviewCountdownHandler` is installed, which the desktop +/// does and mobile does not). +public enum ReviewCountdownCard { + /// Tool-call name that flags a card as the review countdown. Deliberately + /// not prefixed `Hook:` so it doesn't fall into the generic hook-card path. + public static let toolName = "Code Review Countdown" + + /// Input key: epoch seconds (Double) at which the review auto-starts. + public static let startAtKey = "startAt" + /// Input key: the parent thread's session key, used to route a button tap + /// (or a context-menu "Stop Code Review") back to the right pending review. + public static let parentSessionKey = "parentSessionKey" +} + +/// What the user did on the countdown card (or its context-menu equivalent). +public enum ReviewCountdownAction: String, Codable, Sendable, Hashable { + /// Skip the remaining delay and begin the review immediately. + case startNow + /// Cancel the pending review (and stop it if already running). + case stop +} + +/// Why an automatic code review was cancelled — drives the wording on the +/// finalized card so an explicit Stop isn't mislabeled as "a new message". +public enum ReviewCancelReason: String, Codable, Sendable, Hashable { + /// The user sent a new follow-up message in the reviewed thread. + case newMessage + /// The user tapped "Stop Review" on the countdown card. + case stopButton + /// The user picked "Stop Code Review" from the thread context menu. + case contextMenu +} diff --git a/Packages/Sources/RxCodeCore/WindowState.swift b/Packages/Sources/RxCodeCore/WindowState.swift index 726f325d..47412f6a 100644 --- a/Packages/Sources/RxCodeCore/WindowState.swift +++ b/Packages/Sources/RxCodeCore/WindowState.swift @@ -227,6 +227,14 @@ public final class WindowState { /// remains pending until a decision is recorded via `planDecisionHandler`. public var presentedPlanToolCallId: String? + // MARK: - Code Review Countdown Handler + + /// Invoked by the interactive review-countdown card when the user taps + /// "Stop Review" or "Start it now". Parameters: (parentSessionKey, action). + /// Set by `AppState` at window init on desktop; left `nil` on mobile, which + /// renders the countdown read-only (the buttons are hidden when this is nil). + public var reviewCountdownHandler: (@MainActor @Sendable (String, ReviewCountdownAction) -> Void)? + // MARK: - UI State public var interactiveTerminal: InteractiveTerminalState? diff --git a/Packages/Sources/RxCodeMarkdown/MarkdownView.swift b/Packages/Sources/RxCodeMarkdown/MarkdownView.swift index d33f07f6..b771fbf6 100644 --- a/Packages/Sources/RxCodeMarkdown/MarkdownView.swift +++ b/Packages/Sources/RxCodeMarkdown/MarkdownView.swift @@ -80,6 +80,7 @@ public struct MarkdownView: View { private let baseURL: URL? private let style: MarkdownStyle private let fadeNewText: Bool + private let expandsHorizontally: Bool private let imageCache: MarkdownImageCache private let onOpenLink: LinkHandler? @@ -94,6 +95,7 @@ public struct MarkdownView: View { baseURL: URL? = nil, style: MarkdownStyle = MarkdownStyle(), fadeNewText: Bool = false, + expandsHorizontally: Bool = true, imageCache: MarkdownImageCache = .shared, onOpenLink: LinkHandler? = nil ) { @@ -103,6 +105,7 @@ public struct MarkdownView: View { self.baseURL = baseURL self.style = style self.fadeNewText = fadeNewText + self.expandsHorizontally = expandsHorizontally self.imageCache = imageCache self.onOpenLink = onOpenLink _observedText = State(initialValue: text) @@ -122,7 +125,7 @@ public struct MarkdownView: View { fadeSegments: fadeSegments ) .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: expandsHorizontally ? .infinity : nil, alignment: .leading) .onChange(of: text) { _, newText in updateFadeState(for: newText) } diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift index b0f14096..2c573989 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -124,6 +124,17 @@ public enum AutopilotOp: String, Codable, Sendable { // all uncommitted files, thread commits only that thread's recorded files. case projectCommitAll case threadCommitFiles + // Fix failing CI (desktop-mediated): spawn a thread seeded with the failing + // GitHub Actions run(s) for the branch and ask the agent to fix it. + case projectFixCI + + // Serializable context menus. The desktop builds the same `[MenuItem]` its + // own context menus render (from hooks) and ships it as JSON; mobile renders + // the identical items. `menuExecuteCommand` runs a `MenuActionCommand` the + // user tapped on mobile through the desktop's shared dispatcher. + case menuForProject + case menuForThread + case menuExecuteCommand // Global search — one call returns on-device thread matches AND published // docs matches for the same query, so mobile gets a single combined result @@ -526,6 +537,46 @@ public struct AutopilotCodeReviewResult: Codable, Sendable { public init(threadId: String) { self.threadId = threadId } } +// MARK: - Serializable context menus + +/// Mobile → desktop: request the serialized project menu. `branch` scopes it to a +/// briefing card's branch (Code Review / Create PR target that branch); `nil` +/// returns the generic project menu addressing the current branch. +public struct AutopilotProjectMenuBody: Codable, Sendable { + public let projectId: UUID + public let branch: String? + public init(projectId: UUID, branch: String? = nil) { + self.projectId = projectId + self.branch = branch + } +} + +/// Desktop → mobile: the serialized context-menu items for a project or thread, +/// built from the same hooks the desktop renders. Mobile decodes and renders the +/// identical `MenuItem`s. +public struct AutopilotMenuResult: Codable, Sendable { + public let items: [MenuItem] + public init(items: [MenuItem]) { self.items = items } +} + +/// Mobile → desktop: a `MenuActionCommand` the user tapped, to run through the +/// desktop's shared menu dispatcher. +public struct AutopilotExecuteCommandBody: Codable, Sendable { + public let command: MenuActionCommand + public init(command: MenuActionCommand) { self.command = command } +} + +/// Desktop → mobile: the outcome of executing a menu command — a thread to +/// navigate to and/or a URL (e.g. a created pull request) to open on the phone. +public struct AutopilotExecuteCommandResult: Codable, Sendable { + public let threadId: String? + public let openURL: String? + public init(threadId: String? = nil, openURL: String? = nil) { + self.threadId = threadId + self.openURL = openURL + } +} + /// Per-project autopilot state powering the mobile context menu. Mirrors the /// desktop's `projectHasSecrets` / `projectHasDocs` / `projectHasReleaseWorkflow` /// checks so the phone can pick the same menu items (Download vs Set Up, etc.). diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift index 3dfa079c..0bd43bb8 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift @@ -50,6 +50,11 @@ public struct SessionSummary: Codable, Sendable, Identifiable { public let parentThreadId: String? /// Short label chip (e.g. `"Code Review"`). `nil` for ordinary threads. public let threadLabel: String? + /// Number of distinct files this thread recorded edits to (the desktop's + /// `ThreadStore` file-edit history). Drives whether the "Commit Files" action + /// is offered — it's hidden when this is `0`. `nil` from older desktops that + /// predate this field, which is treated as "unknown" (action still shown). + public let changedFileCount: Int? public init( id: String, @@ -65,7 +70,8 @@ public struct SessionSummary: Codable, Sendable, Identifiable { queuedMessages: [QueuedUserMessage]? = nil, hasUncheckedCompletion: Bool = false, parentThreadId: String? = nil, - threadLabel: String? = nil + threadLabel: String? = nil, + changedFileCount: Int? = nil ) { self.id = id self.projectId = projectId @@ -81,10 +87,11 @@ public struct SessionSummary: Codable, Sendable, Identifiable { self.hasUncheckedCompletion = hasUncheckedCompletion self.parentThreadId = parentThreadId self.threadLabel = threadLabel + self.changedFileCount = changedFileCount } private enum CodingKeys: String, CodingKey { - case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress, todos, queuedMessages, hasUncheckedCompletion, parentThreadId, threadLabel + case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress, todos, queuedMessages, hasUncheckedCompletion, parentThreadId, threadLabel, changedFileCount } public init(from decoder: Decoder) throws { @@ -103,9 +110,26 @@ public struct SessionSummary: Codable, Sendable, Identifiable { hasUncheckedCompletion = try container.decodeIfPresent(Bool.self, forKey: .hasUncheckedCompletion) ?? false parentThreadId = try container.decodeIfPresent(String.self, forKey: .parentThreadId) threadLabel = try container.decodeIfPresent(String.self, forKey: .threadLabel) + changedFileCount = try container.decodeIfPresent(Int.self, forKey: .changedFileCount) } } +public extension SessionSummary { + /// Canonical chip label stamped on `[Code Review]` threads (manual or + /// hook-spawned). Matches the desktop's `AppState.manualCodeReviewLabel`. + static let codeReviewLabel = "Code Review" + + /// True when this summary *is* a `[Code Review]` thread. Such threads hide + /// the "Code Review" / "Commit Files" actions since you don't review or + /// commit a review thread itself. + var isCodeReviewThread: Bool { threadLabel == Self.codeReviewLabel } + + /// Whether the "Commit Files" action should be offered for this thread. + /// Hidden only when the desktop positively reported zero recorded file edits; + /// an unknown count (`nil`, from an older desktop) keeps the action visible. + var hasRecordedFileChanges: Bool { changedFileCount.map { $0 > 0 } ?? true } +} + public struct SessionUpdatePayload: Codable, Sendable { public enum Kind: String, Codable, Sendable { case messageAppended diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index f1cea31f..cd3286d7 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -462,15 +462,26 @@ public struct ProjectBranchInfo: Codable, Sendable, Equatable { /// Optional for backward compatibility with older desktops that only sent /// the current branch. public let availableBranches: [String]? + /// Whether the project's working tree has uncommitted changes (`git status` + /// is non-empty). Drives whether the project-level "Commit All Changes" + /// action is offered — it's hidden when this is `false`. `nil` from older + /// desktops that predate this field, treated as "unknown" (action shown). + public let hasUncommittedChanges: Bool? - public init(projectId: UUID, currentBranch: String, availableBranches: [String]? = nil) { + public init( + projectId: UUID, + currentBranch: String, + availableBranches: [String]? = nil, + hasUncommittedChanges: Bool? = nil + ) { self.projectId = projectId self.currentBranch = currentBranch self.availableBranches = availableBranches + self.hasUncommittedChanges = hasUncommittedChanges } private enum CodingKeys: String, CodingKey { - case projectId, currentBranch, availableBranches + case projectId, currentBranch, availableBranches, hasUncommittedChanges } public init(from decoder: Decoder) throws { @@ -478,6 +489,7 @@ public struct ProjectBranchInfo: Codable, Sendable, Equatable { projectId = try c.decode(UUID.self, forKey: .projectId) currentBranch = try c.decode(String.self, forKey: .currentBranch) availableBranches = try c.decodeIfPresent([String].self, forKey: .availableBranches) + hasUncommittedChanges = try c.decodeIfPresent(Bool.self, forKey: .hasUncommittedChanges) } } diff --git a/Packages/Tests/RxCodeCoreTests/MenuItemTests.swift b/Packages/Tests/RxCodeCoreTests/MenuItemTests.swift new file mode 100644 index 00000000..247a25a6 --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/MenuItemTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import RxCodeCore + +/// The whole premise of the cross-platform menu is that the desktop's +/// `[MenuItem]` survives a JSON round trip so mobile can render the identical +/// items. These tests pin that contract plus the deep-link helper. +final class MenuItemTests: XCTestCase { + func testMenuTreeRoundTripsThroughJSON() throws { + let projectId = UUID() + let items: [MenuItem] = [ + MenuItem( + id: "a", + title: "Code Review", + systemImage: "checklist", + action: .command(MenuActionCommand(kind: .projectCodeReview, projectId: projectId, branch: "fix")) + ), + .separator(id: "sep"), + MenuItem( + id: "b", + title: "Set Up Secrets", + systemImage: "key.fill", + action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.secretsSetup, projectId: projectId)) + ), + MenuItem( + id: "c", + title: "More", + children: [ + MenuItem( + id: "c1", + title: "Commit Files", + role: .destructive, + action: .command(MenuActionCommand(kind: .threadCommitFiles, sessionId: "sess-1")) + ), + ] + ), + ] + + let data = try JSONEncoder().encode(items) + let decoded = try JSONDecoder().decode([MenuItem].self, from: data) + XCTAssertEqual(decoded, items) + } + + func testAsyncCommandRoundTripsAndDefaultsWhenAbsent() throws { + // The flag survives a JSON round trip… + let async = MenuActionCommand(kind: .projectCreatePullRequest, projectId: UUID(), isAsync: true) + let data = try JSONEncoder().encode(async) + XCTAssertTrue(try JSONDecoder().decode(MenuActionCommand.self, from: data).isAsync) + + // …and decoding a payload from a peer that predates the field (no + // `isAsync` key) defaults to a synchronous command rather than failing. + let legacy = Data(#"{"kind":"projectCreatePullRequest"}"#.utf8) + let decoded = try JSONDecoder().decode(MenuActionCommand.self, from: legacy) + XCTAssertEqual(decoded.kind, .projectCreatePullRequest) + XCTAssertFalse(decoded.isAsync) + } + + func testDeepLinkBuildAndParse() throws { + let projectId = UUID() + let url = MenuDeepLink.url(action: MenuDeepLink.releaseCreate, projectId: projectId, sessionId: "s1") + let parsed = try XCTUnwrap(MenuDeepLink.parse(url)) + XCTAssertEqual(parsed.action, MenuDeepLink.releaseCreate) + XCTAssertEqual(parsed.projectId, projectId) + XCTAssertEqual(parsed.sessionId, "s1") + } + + func testParseRejectsUnrelatedURL() { + XCTAssertNil(MenuDeepLink.parse(URL(string: "rxcode://secrets/add?repo=a/b")!)) + XCTAssertNil(MenuDeepLink.parse(URL(string: "https://example.com/menu/x")!)) + } +} diff --git a/RxCode/App/AppState+CIStatus.swift b/RxCode/App/AppState+CIStatus.swift index ed95f16a..7f91277f 100644 --- a/RxCode/App/AppState+CIStatus.swift +++ b/RxCode/App/AppState+CIStatus.swift @@ -175,9 +175,52 @@ extension AppState { /// Fire-and-forget fix thread using the project's default agent/model/permission. private func startAutoCIFix(for project: Project, status: ProjectCIStatus) async { - let branch = status.branch ?? "the current branch" - let prLine = status.prNumber.map { "PR #\($0) — " } ?? "" - let failingList = status.failing + let prompt = Self.ciFixPrompt(status: status, fallbackBranch: status.branch) + do { + _ = try await sendCrossProject( + projectId: project.id, + threadId: nil, + prompt: prompt, + waitForResponse: false + ) + logger.info("Started auto CI-fix thread for project \(project.name, privacy: .public)") + } catch { + logger.error("Failed to start auto CI-fix thread: \(error.localizedDescription)") + } + } + + // MARK: - Manual fix (context menu) + + /// Start a fix thread for a failing-CI branch from the briefing card / project + /// context menu. Mirrors the automatic fix but returns the new thread id so the + /// caller (desktop tap or relayed mobile command) can navigate to it. `branch` + /// is nil for a generic project menu — the current branch's status is used. + @discardableResult + func createCIFixForBranch(project: Project, branch: String?) async throws -> String { + let status: ProjectCIStatus? + if let branch, !branch.isEmpty { + status = ciStatus(forProjectId: project.id, branch: branch) + } else { + status = ciStatusByProject[project.id] + } + let prompt = Self.ciFixPrompt(status: status, fallbackBranch: branch) + let result = try await sendCrossProject( + projectId: project.id, + threadId: nil, + prompt: prompt, + waitForResponse: false + ) + if let error = result.error { throw CodeReviewError.sendFailed(error) } + return result.threadId + } + + /// Build the fix prompt from a known CI status, seeding the failing run list / + /// branch / PR context. `fallbackBranch` names the branch when the status is + /// missing or carries no branch (e.g. a generic project menu). + static func ciFixPrompt(status: ProjectCIStatus?, fallbackBranch: String?) -> String { + let branch = status?.branch ?? fallbackBranch ?? "the current branch" + let prLine = status?.prNumber.map { "PR #\($0) — " } ?? "" + let failingList = (status?.failing ?? []) .map { wf in let name = wf.workflowName ?? "workflow" if let url = wf.htmlUrl { return "- \(name): \(url)" } @@ -185,7 +228,7 @@ extension AppState { } .joined(separator: "\n") - let prompt = """ + return """ GitHub Actions CI is failing on branch `\(branch)`. \(prLine)Please fix it. Failing workflow run(s): @@ -195,18 +238,6 @@ extension AppState { find the root cause, apply a fix, and run the relevant checks locally to \ confirm CI will pass before finishing. """ - - do { - _ = try await sendCrossProject( - projectId: project.id, - threadId: nil, - prompt: prompt, - waitForResponse: false - ) - logger.info("Started auto CI-fix thread for project \(project.name, privacy: .public)") - } catch { - logger.error("Failed to start auto CI-fix thread: \(error.localizedDescription)") - } } // MARK: - Helpers diff --git a/RxCode/App/AppState+CodeReview.swift b/RxCode/App/AppState+CodeReview.swift index 69861b2c..e4b42b62 100644 --- a/RxCode/App/AppState+CodeReview.swift +++ b/RxCode/App/AppState+CodeReview.swift @@ -159,6 +159,13 @@ extension AppState { 2. Judge whether the changes correctly and safely accomplish the work described above. Look for bugs, missed requirements, regressions, security issues, and obvious quality problems. 3. List the specific, actionable issues you find (file + line where possible). + ## How to write your final message + Your final message is what gets handed back — it cannot see your tool calls or + the files you read. Make it complete and self-contained: summarize your overall + judgment, then for every issue give the file and line, explain what is wrong and + why, and state concretely how to fix it. Don't reference "see above" or a tool + call — restate everything inline. + End your reply with a single line — exactly one of: `\(reviewMarker) PASS` (the changes look good as-is) `\(reviewMarker) FAIL` (changes are needed) @@ -191,11 +198,20 @@ extension AppState { ## What to do Inspect the changed files and judge whether the change correctly and safely accomplishes the task. Look for bugs, missed requirements, regressions, and obvious quality problems. + ## How to write your final message + Your final message is what gets handed back — it cannot see your tool calls or + the files you read. Make it complete and self-contained: + - Summarize what you reviewed and your overall judgment in a sentence or two. + - For every issue, give the file and line (e.g. `Foo.swift:42`), explain what + is wrong and why, and state concretely how to fix it. Quote the snippet when + it makes the fix unambiguous. Don't reference "see above" or a tool call. + End your reply with a single line — exactly one of: `\(reviewMarker) PASS` (the change is good as-is) `\(reviewMarker) FAIL` (changes are needed) - If you FAIL the review, list the specific, actionable issues to fix above that line. + If you FAIL the review, put the full, self-contained list of actionable issues + above that line. """ } } diff --git a/RxCode/App/AppState+Commit.swift b/RxCode/App/AppState+Commit.swift index 73c368da..45501c08 100644 --- a/RxCode/App/AppState+Commit.swift +++ b/RxCode/App/AppState+Commit.swift @@ -23,6 +23,49 @@ enum CommitFilesError: LocalizedError { extension AppState { static let manualCommitLabel = "Commit" + // MARK: - Commit affordance gating + + /// Whether the thread-level "Commit Files" action should be offered: hidden + /// when the thread recorded no file edits. Read directly from the local + /// `ThreadStore`, so it's always accurate on the desktop. + func threadHasFileChanges(sessionId: String) -> Bool { + threadStore.fileEditCount(sessionId: sessionId) > 0 + } + + /// Whether the project-level "Commit All Changes" action should be offered. + /// Hidden only when the working tree is positively known to be clean; an + /// unresolved project (no cached entry yet) keeps the action visible so a + /// not-yet-computed status never hides a valid commit. See `projectGitDirty`. + func projectHasUncommittedChanges(_ projectId: UUID) -> Bool { + projectGitDirty[projectId] ?? true + } + + /// Recompute the working-tree dirty flag for every known project and publish + /// the result into `projectGitDirty`. Git calls run concurrently so a large + /// project list doesn't serialize on disk I/O. A failed/`nil` status (not a + /// repo, transient error) leaves the project "unknown" rather than marking it + /// clean, so the Commit All action isn't hidden on a hiccup. + func refreshProjectGitDirty() async { + let inputs = projects.map { (id: $0.id, path: $0.path) } + let results = await withTaskGroup(of: (UUID, Bool?).self) { group in + for input in inputs { + group.addTask { + let status = await GitHelper.run(["status", "--porcelain"], at: input.path) + let dirty = status.map { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + return (input.id, dirty) + } + } + var collected: [UUID: Bool] = [:] + for await (id, dirty) in group { + if let dirty { collected[id] = dirty } + } + return collected + } + projectGitDirty = results + } + /// Send a commit-only follow-up into the selected thread. The prompt names /// the files recorded for that thread so the agent can avoid staging /// unrelated work. diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index 8f5c591b..92323047 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -778,6 +778,9 @@ extension AppState { let wasHookInjectedTurn = isSetupSession( kind: HookSetupKind.commitPush, sessionKey: sessionKey + ) || isSetupSession( + kind: HookSetupKind.sendMessage, + sessionKey: sessionKey ) // After-session-stop hooks: shown only, not re-saved. This diff --git a/RxCode/App/AppState+CrossProjectSend.swift b/RxCode/App/AppState+CrossProjectSend.swift index 29cd64f6..acfd3754 100644 --- a/RxCode/App/AppState+CrossProjectSend.swift +++ b/RxCode/App/AppState+CrossProjectSend.swift @@ -146,6 +146,14 @@ extension AppState { allSessionSummaries[idx].threadLabel = threadLabel allSessionSummaries[idx].skipHooks = skipHooks } + // If this is the spawned code-review thread, hand its now-known + // session id to the scheduler so a cancellation (new message / Stop) + // can interrupt the exact in-flight review — and apply a stop that + // was requested before the id was known. Runs before the response is + // awaited below, closing the startup race. + if threadLabel == Self.manualCodeReviewLabel, let parent = parentThreadId { + reviewScheduler?.registerRunningReviewThread(parentSessionKey: parent, reviewThreadId: resolvedThreadIdForReturn) + } } if !waitForResponse { diff --git a/RxCode/App/AppState+Hooks.swift b/RxCode/App/AppState+Hooks.swift index e3c3c838..7b32ec06 100644 --- a/RxCode/App/AppState+Hooks.swift +++ b/RxCode/App/AppState+Hooks.swift @@ -44,11 +44,11 @@ extension AppState { ) } - func projectContextMenuItems(for project: Project) -> [HookMenuItem] { - hookManager.projectContextMenuItems(ProjectContextMenuPayload(project: project)) + func projectContextMenuItems(for project: Project, branch: String? = nil) -> [MenuItem] { + hookManager.projectContextMenuItems(ProjectContextMenuPayload(project: project, branch: branch)) } - func threadContextMenuItems(for session: ChatSession.Summary) -> [HookMenuItem] { + func threadContextMenuItems(for session: ChatSession.Summary) -> [MenuItem] { guard let project = projects.first(where: { $0.id == session.projectId }) else { return [] } return hookManager.threadContextMenuItems(ThreadContextMenuPayload(project: project, session: session)) } @@ -164,4 +164,71 @@ extension AppState { await self?.sendPrompt(prompt, skipAppendingUserMessage: true, isStopHookReprompt: true, in: window) } } + + // MARK: - Code-review auto-fix + + /// How many times a failing code review may auto-continue the reviewed thread + /// to fix the reported problems before we give up, so a never-passing review + /// can't loop the agent forever. + static let maxReviewFixReprompts = 3 + + /// Feed a failing code review's feedback back into the reviewed thread as a + /// new fix turn so the agent can address the reviewer's findings, then + /// finish — at which point the Code Review hook runs again on the fixed + /// change (fix → review → fix). Bounded per session by `maxReviewFixReprompts`. + /// Returns the 1-based attempt number started, or `nil` if the cap was already + /// reached (the caller surfaces a "stopped" card instead of looping). The + /// counter resets when review passes or the user sends a real message (see + /// `sendPrompt`). Mirrors `repromptAfterStopHookFailure`: rendered as an + /// auto-continue card (not a user bubble) and sent with `isStopHookReprompt` + /// so it doesn't reset its own loop counter. + @discardableResult + func repromptAfterReviewFailure(feedback: String, project: Project, sessionKey: String) -> Int? { + let attempts = reviewRoundBySession[sessionKey, default: 0] + guard attempts < Self.maxReviewFixReprompts else { + logger.warning("Code-review auto-fix cap (\(Self.maxReviewFixReprompts)) reached for session \(sessionKey, privacy: .public); not auto-continuing.") + return nil + } + let attempt = attempts + 1 + reviewRoundBySession[sessionKey] = attempt + + let detail = feedback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "(the reviewer requested changes but gave no detail)" + : feedback + let prompt = """ + A code review of your change requested changes (REVIEW_RESULT: FAIL). Address the issues below, then finish: + + \(detail) + """ + + // Render the auto-continue as its own card (see `ToolResultView`'s + // `isAutoContinueTool`) rather than a user bubble, so it reads as a + // system action instead of something the user typed. + updateState(sessionKey) { state in + let toolCall = ToolCall( + id: UUID().uuidString, + name: ToolCall.autoContinueToolName, + input: [ + "summary": .string("Code review requested changes — continuing (attempt \(attempt) of \(Self.maxReviewFixReprompts)).") + ], + result: detail, + isError: false + ) + state.messages.append(ChatMessage( + id: UUID(), + role: .assistant, + blocks: [.toolCall(toolCall)], + isResponseComplete: true + )) + } + + let window = WindowState() + window.selectedProject = project + window.currentSessionId = sessionKey + + Task { [weak self] in + await self?.sendPrompt(prompt, skipAppendingUserMessage: true, isStopHookReprompt: true, in: window) + } + return attempt + } } diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift index f87d61dd..264b1389 100644 --- a/RxCode/App/AppState+Lifecycle.swift +++ b/RxCode/App/AppState+Lifecycle.swift @@ -234,6 +234,8 @@ extension AppState { // CLI is still the transcript backend (replay on thread open), but // it does not drive thread discovery. allSessionSummaries = threadStore.loadAllSummaries() + // Restore persisted code-review verdicts so sidebar review dots survive a reload. + reviewPassedBySession = threadStore.loadReviewVerdicts() autoArchiveExpiredSessionsIfNeeded() await autoDeleteExpiredSessionsIfNeeded() purgeStaleBranchBriefingsIfNeeded() @@ -408,6 +410,16 @@ extension AppState { Task { await self.respondToPlanDecision(toolUseId: toolUseId, action: action, in: window) } } + // Install the review-countdown handler. The Stop / Start-now buttons on + // the countdown card route through here to the review scheduler. + window.reviewCountdownHandler = { [weak self] parentSessionKey, action in + guard let self else { return } + switch action { + case .startNow: self.reviewScheduler.startNow(parentSessionKey: parentSessionKey) + case .stop: self.reviewScheduler.cancel(parentSessionKey: parentSessionKey, reason: .stopButton) + } + } + // Hydrate per-window draft queues from disk-persisted queues so messages // typed-while-streaming survive an app relaunch. for (key, queue) in persistedQueues where window.draftQueues[key] == nil { diff --git a/RxCode/App/AppState+MenuDispatch.swift b/RxCode/App/AppState+MenuDispatch.swift new file mode 100644 index 00000000..20cd649b --- /dev/null +++ b/RxCode/App/AppState+MenuDispatch.swift @@ -0,0 +1,178 @@ +import AppKit +import Foundation +import os +import RxCodeCore + +/// Errors raised while dispatching a serialized `MenuActionCommand`. +enum MenuDispatchError: LocalizedError { + case unknownProject + case unknownThread + case unresolvedBranch + + var errorDescription: String? { + switch self { + case .unknownProject: return "Couldn't find the project for this menu action." + case .unknownThread: return "Couldn't find the thread for this menu action." + case .unresolvedBranch: return "Couldn't determine the current branch." + } + } +} + +/// Result of running a menu command, so callers (desktop tap or an inbound +/// mobile execute-action request) can navigate or open a URL afterward. +struct MenuCommandResult: Sendable { + /// A spawned / affected thread id to navigate to, if any. + var threadId: String? + /// A URL to open (e.g. a freshly created pull request), if any. + var openURL: URL? +} + +extension AppState { + // MARK: - Command dispatch (shared by desktop tap + mobile relay) + + /// Single entry point for every serialized `MenuActionCommand`. The desktop + /// menu handler and the mobile execute-action request both route through + /// here, so the work runs identically whichever side initiated it. Reuses the + /// existing project/thread action methods. + @discardableResult + func dispatchMenuCommand(_ command: MenuActionCommand) async throws -> MenuCommandResult { + switch command.kind { + case .projectCommitAll: + let project = try requireProject(command.projectId) + return MenuCommandResult(threadId: try await commitAllChangesForProject(project: project)) + + case .projectCreatePullRequest: + let project = try requireProject(command.projectId) + // Honor an explicit branch (briefing cards are per-branch); otherwise + // resolve the project's current branch. + if let branch = command.branch, !branch.isEmpty { + return MenuCommandResult(openURL: try await createPullRequestForBranch(project: project, branch: branch)) + } + return MenuCommandResult(openURL: try await createPullRequestForCurrentBranch(project: project)) + + case .projectCodeReview: + let project = try requireProject(command.projectId) + let branch: String + if let explicit = command.branch, !explicit.isEmpty { + branch = explicit + } else if let current = await GitHelper.currentBranch(at: project.path), !current.isEmpty { + branch = current + } else { + throw MenuDispatchError.unresolvedBranch + } + return MenuCommandResult(threadId: try await createCodeReviewForBranch(project: project, branch: branch)) + + case .projectFixCI: + let project = try requireProject(command.projectId) + // Honor an explicit branch (briefing cards are per-branch); a generic + // project menu passes nil and the fix resolves the current branch. + let branch = command.branch.flatMap { $0.isEmpty ? nil : $0 } + return MenuCommandResult(threadId: try await createCIFixForBranch(project: project, branch: branch)) + + case .threadCodeReview: + let sessionId = try requireSession(command.sessionId) + return MenuCommandResult(threadId: try await createCodeReviewForThread(sessionId: sessionId)) + + case .threadCommitFiles: + let sessionId = try requireSession(command.sessionId) + return MenuCommandResult(threadId: try await commitFilesForThread(sessionId: sessionId)) + + case .threadStopCodeReview: + let sessionId = try requireSession(command.sessionId) + // Cancel the pending countdown and/or stop the running review thread. + reviewScheduler.cancel(parentSessionKey: sessionId, reason: .contextMenu) + return MenuCommandResult() + } + } + + private func requireProject(_ id: UUID?) throws -> Project { + guard let id, let project = projects.first(where: { $0.id == id }) else { + throw MenuDispatchError.unknownProject + } + return project + } + + private func requireSession(_ id: String?) throws -> String { + guard let id, !id.isEmpty else { throw MenuDispatchError.unknownThread } + return id + } + + // MARK: - Deep links (desktop-local sheets / setup flows) + + /// Map a `rxcode://menu/…` deep link to the desktop's existing sheet / setup + /// requests. Mobile maps the same links to its own sheets; the desktop reuses + /// the request properties the inline menus already set. + func handleMenuDeepLink(_ parsed: MenuDeepLink.Parsed) { + guard let projectId = parsed.projectId, + let project = projects.first(where: { $0.id == projectId }) else { return } + + switch parsed.action { + case MenuDeepLink.secretsSetup: + secretsSetupRequest = SecretsSetupRequest( + repoFullName: project.gitHubRepo, + projectPath: project.path, + filename: nil + ) + case MenuDeepLink.secretsDownload: + secretsDownloadRequest = project + case MenuDeepLink.docsSetup: + docsSetupRequest = DocsSetupRequest(projectId: project.id, repoFullName: project.gitHubRepo) + case MenuDeepLink.docsSearch: + docsSearchRequest = UUID() + case MenuDeepLink.releaseSetup: + releaseSetupRequest = ReleaseSetupRequest(projectId: project.id, repoFullName: project.gitHubRepo) + case MenuDeepLink.releaseCreate: + releaseCreateRequest = project + case MenuDeepLink.ciSetup: + ciSetupRequest = CISetupRequest(repoFullName: project.gitHubRepo, projectPath: project.path) + default: + break + } + } + + // MARK: - Desktop menu action handler + + /// Convenience handler that selects the spawned/affected thread in `window` + /// after a command runs — preserving the pre-migration behavior where code + /// review / commit actions navigated to the new thread. + func desktopMenuActionHandler(navigatingIn window: WindowState) -> MenuActionHandler { + desktopMenuActionHandler { [weak self] threadId in + self?.selectSession(id: threadId, in: window) + } + } + + /// The `MenuActionHandler` to install (`.menuActionHandler(...)`) on desktop + /// context menus so a tapped `MenuItem` dispatches locally: commands run + /// through `dispatchMenuCommand`; deep links open their local sheet. `onThread` + /// is invoked with a spawned thread id so the caller can navigate if it wants. + func desktopMenuActionHandler(onThread: @escaping (String) -> Void = { _ in }) -> MenuActionHandler { + MenuActionHandler { [weak self] action in + guard let self else { return } + switch action { + case .command(let command): + Task { @MainActor in + // Async commands (push + open PR, code review, …) block on real + // work, so show the hook loading dialog and keep it up until the + // operation finishes — mirroring mobile's action dialog. + if command.isAsync { self.hookProgressStatus = command.progressStatus } + defer { if command.isAsync { self.hookProgressStatus = nil } } + do { + let result = try await self.dispatchMenuCommand(command) + if let url = result.openURL { NSWorkspace.shared.open(url) } + if let threadId = result.threadId { onThread(threadId) } + } catch { + // Surface the failure to the user — not just the log — so a + // failed command (e.g. Create Pull Request) doesn't silently + // vanish with the loading dialog. Rendered by `HookUIModifier`. + self.logger.error("Menu command failed: \(error.localizedDescription, privacy: .public)") + self.hookErrorMessage = error.localizedDescription + } + } + case .deepLink(let url): + if let parsed = MenuDeepLink.parse(url) { + self.handleMenuDeepLink(parsed) + } + } + } + } +} diff --git a/RxCode/App/AppState+Messaging.swift b/RxCode/App/AppState+Messaging.swift index 64b902d1..462fe9a1 100644 --- a/RxCode/App/AppState+Messaging.swift +++ b/RxCode/App/AppState+Messaging.swift @@ -262,9 +262,18 @@ extension AppState { // A real user turn clears the stop-hook auto-continue tally so a fresh // round of fix→fail→fix gets the full retry budget again. A reprompt - // turn (this method calling itself) must not reset it. + // turn (this method calling itself) must not reset it. The code-review + // fix-round counter resets on the same terms (and the review fix turn is + // also sent with `isStopHookReprompt`, so it won't reset its own bound). if !isStopHookReprompt { stopHookRepromptCounts[sessionKey] = nil + reviewRoundBySession[sessionKey] = nil + // A new follow-up message supersedes any pending/in-flight automatic + // code review for this thread: cancel the countdown and stop the + // review thread if it's already running. Auto-fix reprompts (sent + // with `isStopHookReprompt`) deliberately don't cancel — they want + // the re-review to run. + reviewScheduler?.cancel(parentSessionKey: sessionKey, reason: .newMessage) } // Apply initialMessages if provided. Refuse to clobber an already- @@ -289,6 +298,13 @@ extension AppState { )) state.inFlightUserAttachments = attachments } + // A genuine user message resets the Send Message hook's once-per-session + // guard so it can fire again. The hook's own injected message marks the + // session *after* this point (in `sendCrossProject`), so it isn't cleared + // here. Stop-hook reprompts aren't user turns and must not reset it. + if !isStopHookReprompt { + clearSetupSession(kind: HookSetupKind.sendMessage, sessionKey: sessionKey) + } } // Insert the placeholder summary before kicking off title generation — diff --git a/RxCode/App/AppState+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift index fb3980ed..3f1137f9 100644 --- a/RxCode/App/AppState+MobileAutopilot.swift +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -366,6 +366,17 @@ extension AppState { let threadId = try await commitAllChangesForProject(project: project) return try encoder.encode(AutopilotCodeReviewResult(threadId: threadId)) + case .projectFixCI: + // Same as the desktop briefing/project "Fix Failing CI" action: spawn + // a thread seeded with the failing run(s) for the branch. Returns the + // new thread id so the phone can navigate to it. + let body = try decodeAutopilotBody(request, as: AutopilotProjectBranchBody.self) + guard let project = projects.first(where: { $0.id == body.projectId }) else { + throw MobileRemoteConfigError.invalidRequest("No project found for the requested id.") + } + let threadId = try await createCIFixForBranch(project: project, branch: body.branch) + return try encoder.encode(AutopilotCodeReviewResult(threadId: threadId)) + case .threadCommitFiles: // Commit only the files recorded for one thread. let body = try decodeAutopilotBody(request, as: AutopilotThreadBody.self) @@ -408,6 +419,35 @@ extension AppState { let written = try writeDecryptedSecrets(files, to: directory, overwrite: body.overwrite) return try encoder.encode(AutopilotProjectSecretsDownloadResult(written: written, conflicts: conflicts)) + // MARK: Serializable context menus + case .menuForProject: + // Build the project's context menu from the same hooks the desktop + // renders and ship it as JSON for mobile to render identically. The + // optional branch scopes briefing-card menus to their branch. + let body = try decodeAutopilotBody(request, as: AutopilotProjectMenuBody.self) + guard let project = projects.first(where: { $0.id == body.projectId }) else { + throw MobileRemoteConfigError.invalidRequest("No project found for the requested id.") + } + return try encoder.encode(AutopilotMenuResult(items: projectContextMenuItems(for: project, branch: body.branch))) + + case .menuForThread: + let body = try decodeAutopilotBody(request, as: AutopilotThreadBody.self) + guard let summary = allSessionSummaries.first(where: { $0.id == body.sessionId }) + ?? threadStore.fetch(id: body.sessionId)?.toSummary() else { + throw MobileRemoteConfigError.invalidRequest("No thread found for the requested id.") + } + return try encoder.encode(AutopilotMenuResult(items: threadContextMenuItems(for: summary))) + + case .menuExecuteCommand: + // Run a tapped command through the same dispatcher the desktop uses, + // returning a thread to navigate to and/or a URL to open on the phone. + let body = try decodeAutopilotBody(request, as: AutopilotExecuteCommandBody.self) + let result = try await dispatchMenuCommand(body.command) + return try encoder.encode(AutopilotExecuteCommandResult( + threadId: result.threadId, + openURL: result.openURL?.absoluteString + )) + // MARK: Global search case .searchThreadsAndDocs: let body = try decodeAutopilotBody(request, as: AutopilotSearchBody.self) diff --git a/RxCode/App/AppState+MobileSnapshots.swift b/RxCode/App/AppState+MobileSnapshots.swift index eea3a997..92fe4117 100644 --- a/RxCode/App/AppState+MobileSnapshots.swift +++ b/RxCode/App/AppState+MobileSnapshots.swift @@ -296,7 +296,8 @@ extension AppState { queuedMessages: queued, hasUncheckedCompletion: sessionStates[summary.id]?.hasUncheckedCompletion ?? false, parentThreadId: summary.parentThreadId, - threadLabel: summary.threadLabel + threadLabel: summary.threadLabel, + changedFileCount: threadStore.fileEditCount(sessionId: summary.id) ) } @@ -516,12 +517,19 @@ extension AppState { group.addTask { async let current = GitHelper.currentBranch(at: input.path) async let list = GitHelper.listLocalBranches(at: input.path) + async let status = GitHelper.run(["status", "--porcelain"], at: input.path) guard let branch = await current else { return nil } let branches = await list + // `nil` (git call failed / not a repo) stays "unknown" so the + // Commit All action isn't hidden on a transient git error. + let dirty = (await status).map { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } return ProjectBranchInfo( projectId: input.id, currentBranch: branch, - availableBranches: branches.isEmpty ? nil : branches + availableBranches: branches.isEmpty ? nil : branches, + hasUncommittedChanges: dirty ) } } diff --git a/RxCode/App/AppState+SessionLifecycle.swift b/RxCode/App/AppState+SessionLifecycle.swift index 2ccc8864..9ac0e5f1 100644 --- a/RxCode/App/AppState+SessionLifecycle.swift +++ b/RxCode/App/AppState+SessionLifecycle.swift @@ -78,6 +78,16 @@ extension AppState { return keys.contains { resolveCurrentSessionId($0) == target } } + /// Redirect-aware removal of a setup marker (mirrors the hook controller's + /// `clearSetupSession`). Used to reset once-per-session hook markers when the + /// user starts a fresh turn. + func clearSetupSession(kind: String, sessionKey: String) { + guard let keys = setupSessionKeys[kind] else { return } + let target = resolveCurrentSessionId(sessionKey) + let stale = keys.filter { resolveCurrentSessionId($0) == target } + setupSessionKeys[kind]?.subtract(stale) + } + /// Spawn a one-shot summarization call to generate a 3–6 word title for the given /// session, then persist it via `renameSession` if the title is still the placeholder. /// No-op if the session was already renamed manually or the LLM call fails. @@ -658,6 +668,60 @@ extension AppState { .id } + /// One-shot completion used by the Send Message hook's condition gate. Honors + /// a per-hook provider/model `overrideSelection`; otherwise routes through the + /// configured `summarizationProvider` (defaulting to the thread's model for the + /// `.selectedClient` case). Returns nil when no engine could run; callers + /// treat that as "do not fire". + func runHookConditionCompletion( + prompt: String, + overrideSelection: (provider: AgentProvider, model: String)?, + fallbackSessionId: String + ) async -> String? { + if let sel = overrideSelection { + switch sel.provider { + case .claudeCode: + return await claude.generatePlainSummary(prompt: prompt, model: sel.model, limit: 200) + case .codex: + return await codex.generateCodexPlainSummary(prompt: prompt, model: sel.model) + case .acp: + break // ACP has no one-shot; fall through to the summarization model. + } + } + + switch summarizationProvider { + case .selectedClient: + let summary = allSessionSummaries.first { $0.id == fallbackSessionId } + let provider = summary?.agentProvider ?? selectedAgentProvider + let model = summary?.model ?? selectedSummarizationModel(for: provider) + switch provider { + case .claudeCode: + return await claude.generatePlainSummary(prompt: prompt, model: model ?? "haiku", limit: 200) + case .codex: + return await codex.generateCodexPlainSummary(prompt: prompt, model: model) + case .acp: + // ACP can't run a one-shot; fall back to a cheap Claude classifier. + return await claude.generatePlainSummary(prompt: prompt, model: "haiku", limit: 200) + } + case .openAI: + guard !openAISummarizationModel.isEmpty else { + return await claude.generatePlainSummary(prompt: prompt, model: "haiku", limit: 200) + } + return await openAISummarization.generatePlainCompletion( + prompt: prompt, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel, + maxTokens: 16 + ) + case .appleFoundationModel: + return await foundationModelSummarization.generatePlainCompletion( + instructions: "You answer strictly with YES or NO.", + prompt: prompt + ) + } + } + func togglePinSession(_ session: ChatSession) async { guard let si = allSessionSummaries.firstIndex(where: { $0.id == session.id }) else { return } allSessionSummaries[si].isPinned.toggle() diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index a6b922a4..5cd970a6 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -427,6 +427,13 @@ final class AppState { didSet { UserDefaults.standard.set(enableAutoCIFix, forKey: "enableAutoCIFix") } } + /// Per-project working-tree dirty flag (`git status` non-empty), refreshed by + /// `refreshProjectGitDirty()`. Gates the project-level "Commit All Changes" + /// action on project rows and briefing cards — the action is hidden only when + /// a project is positively known to be clean (`false`). A missing entry means + /// "not yet computed" and keeps the action visible. Held in memory only. + var projectGitDirty: [UUID: Bool] = [:] + /// Latest CI status per project (current branch), refreshed by the poller in /// `AppState+CIStatus.swift`. Held in memory only. var ciStatusByProject: [UUID: ProjectCIStatus] = [:] @@ -978,6 +985,10 @@ final class AppState { var hookChoiceRequest: HookChoiceRequest? /// Non-nil while a hook is awaiting a confirm/cancel decision. var hookConfirmRequest: HookConfirmRequest? + /// Non-nil to surface a failed menu/hook command as an alert (e.g. a desktop + /// context-menu command like Create Pull Request that threw). Rendered by + /// `HookUIModifier`, so every desktop context-menu surface is covered. + var hookErrorMessage: String? /// Hook-supplied banners, keyed by surface. Each surface renders its items /// at their requested position (see `HookBannerHost`). var hookBanners: [HookBannerSurface: [HookBannerItem]] = [:] @@ -1065,6 +1076,9 @@ final class AppState { var hookController: AppStateHookController! /// Registry + dispatcher for lifecycle hooks. See `registerBuiltInHooks()`. var hookManager: HookManager! + /// Debounces and cancels automatic code reviews (countdown card + Stop / + /// Start-now / stop-on-new-message). Driven by `CodeReviewHook`. + var reviewScheduler: ReviewScheduler! /// Weak refs to every `WindowState` that's been wired up via `setupChatBridge`. /// Used by AppState-driven queue maintenance (e.g. `flushNextQueuedMessageIfNeeded`) @@ -1234,6 +1248,7 @@ final class AppState { let hookController = AppStateHookController(app: self) self.hookController = hookController self.hookManager = HookManager(controller: hookController) + self.reviewScheduler = ReviewScheduler(app: self) registerBuiltInHooks() } @@ -1249,6 +1264,10 @@ final class AppState { hookManager.register(CINotificationHook()) hookManager.register(RemoteConfigNotificationHook()) #if os(macOS) + // Menu hook first so the standard project/thread actions (code review, + // commit, create PR) lead the context menu, followed by the autopilot + // setup items (secrets, docs, release, CI). + hookManager.register(ActionsMenuHook()) hookManager.register(AutopilotSecretsHook()) hookManager.register(AutopilotDocsHook()) hookManager.register(AutopilotReleaseHook()) @@ -1259,6 +1278,7 @@ final class AppState { // must come before CommitPushHook so the commit gate sees the verdict. hookManager.register(CodeReviewHook()) hookManager.register(CommitPushHook()) + hookManager.register(SendMessageHook()) } diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 5b1979ac..dfd07d64 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -1605,6 +1605,9 @@ }, "After the session is finalized, the change is sent to a linked [Code Review] thread that runs no hooks. If the review requests changes, its notes are sent back into this thread so the agent keeps fixing and is re-reviewed (up to 3 times)." : { + }, + "After the session is finalized, your message is sent to the assistant — as a follow-up in this thread or into a new linked thread that runs no hooks. With a condition, a model first decides yes/no. Fires once per session and resets when you send a new message." : { + }, "Agent Availability" : { "localizations" : { @@ -3355,6 +3358,9 @@ } } } + }, + "Command Failed" : { + }, "Commands" : { "localizations" : { @@ -3415,6 +3421,12 @@ }, "Commit when a session finishes" : { + }, + "Condition" : { + + }, + "Condition model" : { + }, "Configuration" : { "localizations" : { @@ -4128,6 +4140,7 @@ } }, "Creating Pull Request…" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -6102,6 +6115,22 @@ } } }, + "Fix Failing CI" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실패한 CI 수정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "修复失败的 CI" + } + } + } + }, "Focus Mode" : { "localizations" : { "en" : { @@ -8661,6 +8690,12 @@ } } } + }, + "New thread" : { + + }, + "New thread label" : { + }, "New thread starts" : { "extractionState" : "stale", @@ -9806,6 +9841,9 @@ } } } + }, + "Only send when a condition is met" : { + }, "Open" : { "localizations" : { @@ -12298,6 +12336,9 @@ }, "Same as thread" : { + }, + "Same thread" : { + }, "Save" : { "localizations" : { @@ -13072,6 +13113,9 @@ } } } + }, + "Send Message" : { + }, "Send test notification" : { "localizations" : { @@ -13088,6 +13132,9 @@ } } } + }, + "Send to" : { + }, "Sends a system notification while RxCode is in the background." : { "localizations" : { @@ -13180,6 +13227,9 @@ } } } + }, + "Set Up CI Update" : { + }, "Set Up Docs" : { "localizations" : { @@ -14061,6 +14111,9 @@ } } } + }, + "Stop Code Review" : { + }, "Stop running tasks" : { "localizations" : { @@ -14158,6 +14211,9 @@ } } } + }, + "Summarization model" : { + }, "Summarization Model" : { "localizations" : { @@ -14612,6 +14668,9 @@ } } } + }, + "Thread model" : { + }, "Thread Model" : { "localizations" : { diff --git a/RxCode/Services/FoundationModelSummarizationService.swift b/RxCode/Services/FoundationModelSummarizationService.swift index de5fa84f..ef3e467f 100644 --- a/RxCode/Services/FoundationModelSummarizationService.swift +++ b/RxCode/Services/FoundationModelSummarizationService.swift @@ -165,6 +165,12 @@ actor FoundationModelSummarizationService { return cleanSummary(raw, limit: 4000) } + /// Generic one-shot completion for an arbitrary prompt (e.g. a hook condition + /// gate). Returns nil when the on-device model is unavailable. + func generatePlainCompletion(instructions: String, prompt: String) async -> String? { + await respond(instructions: instructions, prompt: prompt) + } + private func respond(instructions: String, prompt: String) async -> String? { guard Self.isAvailable else { return nil } return await respond(instructions: instructions, prompt: prompt, allowRollingWindow: true) diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index 8270c55b..94a48fb4 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -176,12 +176,9 @@ final class AppStateHookController: HookController { // Skip the injected review instruction (the only user message). continue case .assistant: - for toolCall in message.toolCalls { - // Hook/auto-continue synthetic cards aren't part of the - // reviewer's own work — skip them. - if toolCall.name.lowercased().hasPrefix("hook:") { continue } - lines.append("• \(toolCall.name)") - } + // Text only — tool calls are deliberately excluded so the review + // result folded back onto the parent thread (and fed into any + // fix turn) is the reviewer's prose, not a list of tool names. let text = message.content.trimmingCharacters(in: .whitespacesAndNewlines) if !text.isEmpty { lines.append(text) } default: @@ -231,20 +228,135 @@ final class AppStateHookController: HookController { } } - func sendThreadMessage(sessionId: String, prompt: String) { + func sendThreadMessage(sessionId: String, prompt: String, setupKind: String?) { guard let app else { return } Task { [weak app] in _ = try? await app?.sendCrossProject( projectId: nil, threadId: sessionId, prompt: prompt, - waitForResponse: false + waitForResponse: false, + setupKind: setupKind ) } } + func evaluateCondition(condition: String, lastAssistantText: String, model: String?, sessionId: String) async -> Bool? { + guard let app else { return nil } + let trimmedCondition = condition.trimmingCharacters(in: .whitespacesAndNewlines) + // An empty condition is treated as "always send" by the caller, but guard + // here too so we never spend a model call on nothing. + guard !trimmedCondition.isEmpty else { return true } + + let prompt = """ + You are a gate that decides whether a follow-up automation should run for a coding chat thread. \ + Answer with exactly one word — YES or NO. No punctuation, no explanation. + + Condition to evaluate: + \(trimmedCondition) + + The assistant's final response this turn: + \(String(lastAssistantText.prefix(4000))) + + Does the condition hold? Reply YES or NO. + """ + + // A per-hook model override (provider-qualified) wins; otherwise the app's + // configured summarization model is used. + let override: (provider: AgentProvider, model: String)? = { + guard let trimmed = model?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } + return resolveAgentModelSelection(storedModel: trimmed, fallbackSessionId: sessionId) + }() + + guard let raw = await app.runHookConditionCompletion( + prompt: prompt, + overrideSelection: override, + fallbackSessionId: sessionId + ) else { return nil } + + let verdict = raw.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + if verdict.hasPrefix("YES") { return true } + if verdict.hasPrefix("NO") { return false } + return nil + } + + // MARK: Review debounce / cancellation + + var reviewCountdownSeconds: TimeInterval { ReviewScheduler.defaultDelaySeconds } + + func awaitReviewGate(parentSessionKey: String, delaySeconds: TimeInterval) async -> Bool { + guard let app else { return true } + let outcome = await app.reviewScheduler.awaitGate(parentSessionKey: parentSessionKey, delaySeconds: delaySeconds) + return outcome == .proceed + } + + func markReviewRunning(parentSessionKey: String) { + app?.reviewScheduler.markRunning(parentSessionKey: parentSessionKey) + } + + func clearReviewRunning(parentSessionKey: String) { + app?.reviewScheduler.clearRunning(parentSessionKey: parentSessionKey) + } + + func reviewWasStopped(parentSessionKey: String) -> Bool { + app?.reviewScheduler.consumeStoppedWhileRunning(parentSessionKey: parentSessionKey) ?? false + } + + func reviewCancelReason(parentSessionKey: String) -> ReviewCancelReason? { + app?.reviewScheduler.cancelReason(parentSessionKey: parentSessionKey) + } + + func threadHasOngoingReview(sessionId: String) -> Bool { + app?.reviewScheduler.hasOngoingReview(parentSessionKey: sessionId) ?? false + } + + func threadHasNewerActivity(sessionId: String) -> Bool { + guard let app else { return false } + let state = app.stateForSession(sessionId) + // A new turn streaming on this thread. + if state.isStreaming { return true } + // Walk back to the latest *meaningful* message, skipping synthetic cards + // inserted by hooks (hook status cards, auto-continue, the review + // countdown). A trailing user message means a follow-up superseded the + // completed turn; an assistant turn — whether it ended with prose or only + // real tool calls (e.g. file edits and no closing text) — is just the + // turn's own response, not newer activity. + for message in state.messages.reversed() { + switch message.role { + case .user: + if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + case .assistant: + // First real assistant message reached → the completed turn's + // response. Synthetic-only / empty rows fall through and we keep + // looking past them. + if Self.isMeaningfulAssistantMessage(message) { return false } + default: + break + } + } + return false + } + + /// Whether an assistant message carries real activity — non-empty prose or at + /// least one non-synthetic tool call. Hook status cards, the auto-continue + /// card, and the review-countdown card are synthetic and don't count. + private static func isMeaningfulAssistantMessage(_ message: ChatMessage) -> Bool { + if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } + return message.blocks.compactMap { $0.toolCall }.contains { !isSyntheticToolName($0.name) } + } + + private static func isSyntheticToolName(_ name: String) -> Bool { + name.lowercased().hasPrefix("hook:") + || name == ToolCall.autoContinueToolName + || name == ReviewCountdownCard.toolName + } + func setReviewPassed(_ passed: Bool, sessionId: String) { app?.reviewPassedBySession[sessionId] = passed + // Persist on the thread row so the sidebar review dot survives a reload. + app?.threadStore.setReviewPassed(sessionId: sessionId, passed: passed) } func reviewPassed(sessionId: String) -> Bool? { @@ -264,6 +376,11 @@ final class AppStateHookController: HookController { } } + func repromptThreadAfterReviewFailure(feedback: String, project: Project, sessionKey: String) -> Int? { + guard let app else { return nil } + return app.repromptAfterReviewFailure(feedback: feedback, project: project, sessionKey: sessionKey) + } + func responseNotificationFallback(from responseText: String) -> String { app?.responseNotificationFallback(from: responseText) ?? "" } @@ -546,6 +663,40 @@ final class AppStateHookController: HookController { app?.projectHasReleaseWorkflow(project) ?? false } + func projectHasCIUpdates(_ project: Project) -> Bool { + app?.projectHasCIUpdates(project) ?? false + } + + func projectHasUncommittedChanges(_ project: Project) -> Bool { + // Default to visible when status is unknown (mirrors the inline menu). + app?.projectHasUncommittedChanges(project.id) ?? true + } + + func projectHasOpenPullRequest(_ project: Project, branch: String?) -> Bool { + guard let app, project.gitHubRepo != nil else { return false } + // For a branch-scoped menu (e.g. a briefing card), check that branch's + // PR status from the branch-keyed map; otherwise use the project's + // current-branch status. Only an *open* PR should hide "Create Pull + // Request" — a closed/merged PR shouldn't. + let status = branch.map { app.ciStatus(forProjectId: project.id, branch: $0) } + ?? app.ciStatusByProject[project.id] + return status?.pullRequestState == .open + } + + func projectCIIsFailing(_ project: Project, branch: String?) -> Bool { + guard let app, project.gitHubRepo != nil else { return false } + // Branch-scoped menu (e.g. a briefing card) checks that branch's CI from + // the branch-keyed map; a generic project menu uses the current-branch + // status. Only an outright failure offers the fix item. + let status = branch.map { app.ciStatus(forProjectId: project.id, branch: $0) } + ?? app.ciStatusByProject[project.id] + return status?.overallState == .failure + } + + func threadHasFileChanges(sessionId: String) -> Bool { + app?.threadHasFileChanges(sessionId: sessionId) ?? false + } + func requestSecretsSetup(project: Project) { app?.secretsSetupRequest = SecretsSetupRequest( repoFullName: project.gitHubRepo, @@ -574,6 +725,13 @@ final class AppStateHookController: HookController { app?.releaseCreateRequest = project } + func requestCISetup(project: Project) { + app?.ciSetupRequest = CISetupRequest( + repoFullName: project.gitHubRepo, + projectPath: project.path + ) + } + // MARK: Setup-session tracking func markSetupSession(kind: String, sessionKey: String) { diff --git a/RxCode/Services/Hooks/HookManager.swift b/RxCode/Services/Hooks/HookManager.swift index 7633f36a..c0227a1e 100644 --- a/RxCode/Services/Hooks/HookManager.swift +++ b/RxCode/Services/Hooks/HookManager.swift @@ -40,11 +40,11 @@ final class HookManager { } } - func projectContextMenuItems(_ payload: ProjectContextMenuPayload) -> [HookMenuItem] { + func projectContextMenuItems(_ payload: ProjectContextMenuPayload) -> [MenuItem] { enabledHooks.flatMap { $0.onProjectContextMenu(payload, controller: controller) } } - func threadContextMenuItems(_ payload: ThreadContextMenuPayload) -> [HookMenuItem] { + func threadContextMenuItems(_ payload: ThreadContextMenuPayload) -> [MenuItem] { enabledHooks.flatMap { $0.onThreadContextMenu(payload, controller: controller) } } diff --git a/RxCode/Services/Hooks/ReviewScheduler.swift b/RxCode/Services/Hooks/ReviewScheduler.swift new file mode 100644 index 00000000..612c47e6 --- /dev/null +++ b/RxCode/Services/Hooks/ReviewScheduler.swift @@ -0,0 +1,188 @@ +import Foundation +import os +import RxCodeCore + +/// Coordinates the debounced start of an automatic code review and its +/// cancellation. Owned by `AppState`, driven by `CodeReviewHook` through the +/// `HookController`. +/// +/// The flow, keyed by the reviewed thread's `parentSessionKey`: +/// 1. The hook inserts a countdown card and calls `awaitGate(...)`, which +/// suspends for `delaySeconds` (default 60). +/// 2. During that window the user can: +/// - tap **Start it now** → `startNow` → the gate resolves `.proceed`, +/// - tap **Stop Review** or send a new message in the thread → `cancel` +/// → the gate resolves `.cancelled`. +/// 3. If nothing happens, the timer fires and the gate resolves `.proceed`. +/// +/// After the gate proceeds the hook runs the review thread; the hook brackets +/// that work with `markRunning` / `clearRunning` so a later `cancel` (a new +/// follow-up message, or the "Stop Code Review" context-menu item) stops the +/// in-flight review thread too. +@MainActor +final class ReviewScheduler { + /// How a pending countdown resolved. + enum GateOutcome { + case proceed + case cancelled + } + + /// Default debounce before an automatic review starts. + static let defaultDelaySeconds: TimeInterval = 60 + + private weak var app: AppState? + private let logger = Logger(subsystem: "com.claudework", category: "ReviewScheduler") + + private struct Gate { + var continuation: CheckedContinuation? + var timer: Task? + } + + /// Pending countdowns awaiting resolution, keyed by parent session key. + private var gates: [String: Gate] = [:] + /// Parent keys whose review thread is currently running (post-gate). + private var running: Set = [] + /// Resolved review-thread session id, keyed by parent. Populated by + /// `registerRunningReviewThread` as soon as `sendCrossProject` stamps the + /// child's linkage — well before the review's response is awaited — so a + /// cancel can interrupt the exact thread instead of guessing from summaries. + private var reviewThreadByParent: [String: String] = [:] + /// Parent keys cancelled while running *before* their review-thread id was + /// known. The stop is applied the instant `registerRunningReviewThread` runs. + private var pendingStop: Set = [] + /// Parent keys we actually stopped while running, so the hook can render a + /// "stopped" card instead of an "ended without verdict" one when its awaited + /// review thread returns empty because we interrupted it. + private var stoppedWhileRunning: Set = [] + /// Why the most recent cancel happened, keyed by parent — drives card copy. + private var cancelReasonByParent: [String: ReviewCancelReason] = [:] + + init(app: AppState) { + self.app = app + } + + /// Suspend until the countdown elapses, the user starts it now, or it is + /// cancelled. Replaces any pre-existing gate for the same key. + func awaitGate(parentSessionKey key: String, delaySeconds: TimeInterval) async -> GateOutcome { + // Fresh review for this thread — clear any state left from a prior one. + stoppedWhileRunning.remove(key) + pendingStop.remove(key) + reviewThreadByParent[key] = nil + cancelReasonByParent[key] = nil + // Defensive: resolve a stale gate for this key before starting a new one. + resolve(key, .cancelled) + + let timer = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(max(0, delaySeconds) * 1_000_000_000)) + guard !Task.isCancelled else { return } + self?.resolve(key, .proceed) + } + return await withCheckedContinuation { continuation in + gates[key] = Gate(continuation: continuation, timer: timer) + } + } + + /// Skip the remaining delay and begin the review now. + func startNow(parentSessionKey key: String) { + logger.debug("[Review] start-now for \(key, privacy: .public)") + resolve(key, .proceed) + } + + /// Cancel a pending countdown and/or stop an in-flight review thread. + func cancel(parentSessionKey key: String, reason: ReviewCancelReason) { + let hadGate = gates[key] != nil + let isRunning = running.contains(key) + guard hadGate || isRunning else { return } + + cancelReasonByParent[key] = reason + if hadGate { + resolve(key, .cancelled) + } + if isRunning { + if let reviewId = reviewThreadByParent[key] { + // Only count it as "stopped" if there was actually a live stream + // to interrupt. If the review child already finished, let its + // real PASS/FAIL verdict stand instead of discarding it. + if app?.interruptReviewThread(sessionId: reviewId) == true { + stoppedWhileRunning.insert(key) + } + } else { + // Review is starting but its thread id isn't known yet; stop it + // the moment `registerRunningReviewThread` registers the id. + pendingStop.insert(key) + } + } + logger.debug("[Review] cancel \(key, privacy: .public) reason=\(reason.rawValue, privacy: .public) gate=\(hadGate, privacy: .public) running=\(isRunning, privacy: .public)") + } + + /// Register the spawned review thread's session id so a cancel can target it + /// directly. Applies a stop immediately if one was requested before now. + func registerRunningReviewThread(parentSessionKey key: String, reviewThreadId: String) { + reviewThreadByParent[key] = reviewThreadId + if pendingStop.remove(key) != nil { + // Apply the stop requested before the id was known. Only mark stopped + // if a live stream was actually interrupted. + if app?.interruptReviewThread(sessionId: reviewThreadId) == true { + stoppedWhileRunning.insert(key) + } + } + } + + /// Whether a review is pending (counting down) or actively running for the + /// thread — drives the "Stop Code Review" context-menu item's visibility. + func hasOngoingReview(parentSessionKey key: String) -> Bool { + gates[key] != nil || running.contains(key) + } + + /// Mark the review thread as running (post-gate, while the hook awaits it). + func markRunning(parentSessionKey key: String) { + running.insert(key) + } + + /// Clear the running flag once the hook's review thread returns. + func clearRunning(parentSessionKey key: String) { + running.remove(key) + reviewThreadByParent[key] = nil + pendingStop.remove(key) + } + + /// One-shot: did we stop this review while it was running? Clears the flag. + func consumeStoppedWhileRunning(parentSessionKey key: String) -> Bool { + stoppedWhileRunning.remove(key) != nil + } + + /// The reason the most recent cancel happened for this thread (non-consuming). + func cancelReason(parentSessionKey key: String) -> ReviewCancelReason? { + cancelReasonByParent[key] + } + + // MARK: - Private + + private func resolve(_ key: String, _ outcome: GateOutcome) { + guard var gate = gates[key], let continuation = gate.continuation else { return } + gate.continuation = nil + gate.timer?.cancel() + gates[key] = nil + continuation.resume(returning: outcome) + } +} + +extension AppState { + /// Interrupt a specific (review) thread's in-flight stream. Returns `true` + /// only when there was actually a live stream to cancel — so callers don't + /// report a review as "stopped" when the child had already finished. + @discardableResult + func interruptReviewThread(sessionId: String) -> Bool { + guard sessionStates[sessionId]?.isStreaming == true else { return false } + let projectId = allSessionSummaries.first(where: { $0.id == sessionId })?.projectId + let project = projectId.flatMap { id in projects.first { $0.id == id } } + let window = WindowState() + window.selectedProject = project + window.currentSessionId = sessionId + // Review threads skip lifecycle hooks, so there are no stop hooks to fire. + Task { [weak self] in + await self?.cancelStreaming(in: window, fireStopHooks: false) + } + return true + } +} diff --git a/RxCode/Services/Hooks/hooks/ActionsMenuHook.swift b/RxCode/Services/Hooks/hooks/ActionsMenuHook.swift new file mode 100644 index 00000000..7cbd2e88 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/ActionsMenuHook.swift @@ -0,0 +1,118 @@ +#if os(macOS) +import Foundation +import RxCodeCore + +/// Supplies the manual one-off action items for **both** the project and thread +/// context menus — Code Review, Commit, Create Pull Request — that used to be +/// hardcoded in the sidebar / briefing / chat-row menus. Routing them through +/// serializable `MenuItem`s means desktop and mobile render the same items, and +/// mobile can fetch them from the desktop over the relay. +/// +/// One hook implements both `onProjectContextMenu` and `onThreadContextMenu` +/// since the two menus share the same "manual action" purpose. These are +/// `.command` actions (real work the desktop performs), not deep links: the +/// desktop dispatches them locally and mobile relays them back via +/// `executeMenuCommand`. +@MainActor +final class ActionsMenuHook: Hook { + let hookID = "builtin.actionsMenu" + + // MARK: - Project menu + + func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem] { + projectItems(for: payload.project, branch: payload.branch, controller: controller) + } + + private func projectItems(for project: Project, branch: String?, controller: any HookController) -> [MenuItem] { + var items: [MenuItem] = [] + // A branch-scoped menu (e.g. a briefing card) addresses `branch`; a + // generic project menu addresses the current branch (carried as nil so + // the dispatcher resolves it). + let branchSuffix = branch.map { ".\($0)" } ?? "" + + // Code review for the branch (or the current branch when unscoped). + items.append(MenuItem( + id: "\(hookID).project.codeReview.\(project.id.uuidString)\(branchSuffix)", + title: branch.map { String(localized: "Code Review for \($0)") } + ?? String(localized: "Code Review for Current Branch"), + systemImage: "checklist", + action: .command(MenuActionCommand(kind: .projectCodeReview, projectId: project.id, branch: branch)) + )) + + // Commit all — only when the working tree has uncommitted changes. + if controller.projectHasUncommittedChanges(project) { + items.append(MenuItem( + id: "\(hookID).project.commitAll.\(project.id.uuidString)", + title: String(localized: "Commit All Changes"), + systemImage: "checkmark.circle", + action: .command(MenuActionCommand(kind: .projectCommitAll, projectId: project.id)) + )) + } + + // Create PR — only when the repo is linked and has no open PR for the + // tracked branch. The command carries `branch` so a briefing card opens a + // PR for its own branch. + if project.gitHubRepo != nil, !controller.projectHasOpenPullRequest(project, branch: branch) { + items.append(MenuItem( + id: "\(hookID).project.createPR.\(project.id.uuidString)\(branchSuffix)", + title: String(localized: "Create Pull Request"), + systemImage: "arrow.triangle.pull", + action: .command(MenuActionCommand(kind: .projectCreatePullRequest, projectId: project.id, branch: branch, isAsync: true)) + )) + } + + // Fix CI — only when GitHub Actions is failing for the branch (or current + // branch when unscoped). Spawns a thread seeded with the failing run(s). + if controller.projectCIIsFailing(project, branch: branch) { + items.append(MenuItem( + id: "\(hookID).project.fixCI.\(project.id.uuidString)\(branchSuffix)", + title: String(localized: "Fix Failing CI"), + systemImage: "wrench.and.screwdriver", + action: .command(MenuActionCommand(kind: .projectFixCI, projectId: project.id, branch: branch)) + )) + } + + return items + } + + // MARK: - Thread menu + + func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { + // A `[Code Review]` thread doesn't review or commit itself. + if payload.session.threadLabel == AppState.manualCodeReviewLabel { return [] } + + var items: [MenuItem] = [] + + // Stop an in-flight automatic review (countdown or running review thread). + // Only shown while a review is actually pending/running for this thread. + if controller.threadHasOngoingReview(sessionId: payload.session.id) { + items.append(MenuItem( + id: "\(hookID).thread.stopCodeReview.\(payload.session.id)", + title: String(localized: "Stop Code Review"), + systemImage: "stop.circle", + role: .destructive, + action: .command(MenuActionCommand(kind: .threadStopCodeReview, sessionId: payload.session.id)) + )) + } + + items.append(MenuItem( + id: "\(hookID).thread.codeReview.\(payload.session.id)", + title: String(localized: "Code Review for this thread"), + systemImage: "checklist", + action: .command(MenuActionCommand(kind: .threadCodeReview, sessionId: payload.session.id)) + )) + + // Commit Files — only when the thread recorded file edits. + if controller.threadHasFileChanges(sessionId: payload.session.id) { + items.append(MenuItem( + id: "\(hookID).thread.commitFiles.\(payload.session.id)", + title: String(localized: "Commit Files"), + systemImage: "checkmark.circle", + action: .command(MenuActionCommand(kind: .threadCommitFiles, sessionId: payload.session.id)) + )) + } + + return items + } +} +#endif diff --git a/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift b/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift index 670c4dcc..e7d718a7 100644 --- a/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift +++ b/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift @@ -21,27 +21,26 @@ final class AutopilotDocsHook: Hook { return "\(repoSlug)-new-project-docs" } - func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [HookMenuItem] { + func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { menuItems(for: payload.project, controller: controller) } - func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [HookMenuItem] { + func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem] { menuItems(for: payload.project, controller: controller) } - private func menuItems(for project: Project, controller: any HookController) -> [HookMenuItem] { + private func menuItems(for project: Project, controller: any HookController) -> [MenuItem] { guard project.gitHubRepo != nil else { return [] } // Docs search was removed from the menu. Once a repo's docs are indexed // there's nothing to do here; otherwise offer to set them up. guard !controller.projectHasDocs(project) else { return [] } return [ - HookMenuItem( + MenuItem( id: "\(hookID).setup.\(project.id.uuidString)", - title: "Set Up Docs", - systemImage: "books.vertical.fill" - ) { - controller.requestDocsSetup(project: project) - } + title: String(localized: "Set Up Docs"), + systemImage: "books.vertical.fill", + action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.docsSetup, projectId: project.id)) + ) ] } diff --git a/RxCode/Services/Hooks/hooks/AutopilotReleaseHook.swift b/RxCode/Services/Hooks/hooks/AutopilotReleaseHook.swift index be9271a6..c8073e71 100644 --- a/RxCode/Services/Hooks/hooks/AutopilotReleaseHook.swift +++ b/RxCode/Services/Hooks/hooks/AutopilotReleaseHook.swift @@ -21,36 +21,34 @@ final class AutopilotReleaseHook: Hook { return "\(repoSlug)-new-project-release" } - func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [HookMenuItem] { + func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { menuItems(for: payload.project, controller: controller) } - func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [HookMenuItem] { + func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem] { menuItems(for: payload.project, controller: controller) } - private func menuItems(for project: Project, controller: any HookController) -> [HookMenuItem] { + private func menuItems(for project: Project, controller: any HookController) -> [MenuItem] { guard project.gitHubRepo != nil else { return [] } if controller.projectHasReleaseWorkflow(project) { return [ - HookMenuItem( + MenuItem( id: "\(hookID).create.\(project.id.uuidString)", - title: "Create Release", - systemImage: "tag.fill" - ) { - controller.requestReleaseCreate(project: project) - } + title: String(localized: "Create Release"), + systemImage: "tag.fill", + action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.releaseCreate, projectId: project.id)) + ) ] } return [ - HookMenuItem( + MenuItem( id: "\(hookID).setup.\(project.id.uuidString)", - title: "Set Up Release Workflow", - systemImage: "tag.fill" - ) { - controller.requestReleaseSetup(project: project) - } + title: String(localized: "Set Up Release Workflow"), + systemImage: "tag.fill", + action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.releaseSetup, projectId: project.id)) + ) ] } diff --git a/RxCode/Services/Hooks/hooks/AutopilotSecretsHook.swift b/RxCode/Services/Hooks/hooks/AutopilotSecretsHook.swift index 306b8e4c..924289c2 100644 --- a/RxCode/Services/Hooks/hooks/AutopilotSecretsHook.swift +++ b/RxCode/Services/Hooks/hooks/AutopilotSecretsHook.swift @@ -21,36 +21,34 @@ final class AutopilotSecretsHook: Hook { await run(project: payload.project, controller: controller) } - func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [HookMenuItem] { + func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { menuItems(for: payload.project, controller: controller) } - func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [HookMenuItem] { + func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem] { menuItems(for: payload.project, controller: controller) } - private func menuItems(for project: Project, controller: any HookController) -> [HookMenuItem] { + private func menuItems(for project: Project, controller: any HookController) -> [MenuItem] { guard project.gitHubRepo != nil else { return [] } if controller.projectHasSecrets(project) { return [ - HookMenuItem( + MenuItem( id: "\(hookID).download.\(project.id.uuidString)", - title: "Download Secret", - systemImage: "key.fill" - ) { - controller.requestSecretsDownload(project: project) - } + title: String(localized: "Download Secret"), + systemImage: "key.fill", + action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.secretsDownload, projectId: project.id)) + ) ] } return [ - HookMenuItem( + MenuItem( id: "\(hookID).setup.\(project.id.uuidString)", - title: "Set Up Secrets", - systemImage: "key.fill" - ) { - controller.requestSecretsSetup(project: project) - } + title: String(localized: "Set Up Secrets"), + systemImage: "key.fill", + action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.secretsSetup, projectId: project.id)) + ) ] } diff --git a/RxCode/Services/Hooks/hooks/CIUpdateHook.swift b/RxCode/Services/Hooks/hooks/CIUpdateHook.swift index aefe10c1..8e744da2 100644 --- a/RxCode/Services/Hooks/hooks/CIUpdateHook.swift +++ b/RxCode/Services/Hooks/hooks/CIUpdateHook.swift @@ -6,8 +6,10 @@ import SwiftUI /// Surfaces a "set up CI auto-update" banner on the new-chat screen when the /// project has local `.github/workflows/*.yml|yaml` files but its repo isn't yet -/// watched for CI auto-updates. Passive — never blocks the chat. Mirrors -/// `DocsHook` / `AutopilotHook`. +/// watched for CI auto-updates, and contributes the matching "Set Up CI Update" +/// item to the project/thread context menus. Passive — never blocks the chat. +/// Mirrors `AutopilotReleaseHook` / `AutopilotDocsHook`, which likewise pair a +/// banner with a context-menu item in one hook. @MainActor final class CIUpdateHook: Hook { let hookID = "builtin.ciupdate" @@ -22,6 +24,36 @@ final class CIUpdateHook: Hook { return "\(repoSlug)-new-project-ci-update" } + // MARK: Context menu + + func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem] { + menuItems(for: payload.project, controller: controller) + } + + func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { + menuItems(for: payload.project, controller: controller) + } + + /// Offers "Set Up CI Update" when the project has a linked GitHub repo that + /// is *not* already watched for CI auto-updates. The "is watched" status + /// comes from the synchronous `ciStatusByRepo` cache (refreshed at launch + /// alongside secrets/docs/release), so an already-set-up repo no longer shows + /// the item. It's a `.deepLink` (presents the CI setup sheet locally) rather + /// than a `.command`, so desktop and mobile each open their own UI. + private func menuItems(for project: Project, controller: any HookController) -> [MenuItem] { + guard project.gitHubRepo != nil else { return [] } + // Already a watched repo → CI auto-update is set up; nothing to offer. + guard !controller.projectHasCIUpdates(project) else { return [] } + return [ + MenuItem( + id: "\(hookID).setup.\(project.id.uuidString)", + title: String(localized: "Set Up CI Update"), + systemImage: "arrow.triangle.2.circlepath", + action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.ciSetup, projectId: project.id)) + ) + ] + } + func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome { guard let bannerID = bannerID(for: payload.project) else { return .ignored } controller.clearBannerDismissal(id: bannerID) diff --git a/RxCode/Services/Hooks/hooks/CodeReviewHook.swift b/RxCode/Services/Hooks/hooks/CodeReviewHook.swift index b5324ca9..24733f1c 100644 --- a/RxCode/Services/Hooks/hooks/CodeReviewHook.swift +++ b/RxCode/Services/Hooks/hooks/CodeReviewHook.swift @@ -7,13 +7,17 @@ import RxCodeCore /// no hooks, using the configured or inherited model) and feeds it the changed /// files, the user's task, and the agent's final response. The reviewer ends its /// reply with `REVIEW_RESULT: PASS` or `REVIEW_RESULT: FAIL`: -/// - PASS → records the verdict so `CommitPushHook` may proceed. -/// - FAIL → sends the review notes back into the original thread as a -/// follow-up prompt so the agent fixes the issues and is then re-reviewed. -/// Bounded by `maxReviewRounds` to stop a fix→fail→fix loop. +/// - PASS → records the verdict so `CommitPushHook` may proceed, and resets +/// the fix-round counter so the next change starts fresh. +/// - FAIL → records not-passed, surfaces the reviewer's suggestions inline on +/// the original thread (folded into the hook card), AND feeds the feedback +/// back into the reviewed thread as a bounded auto-continue fix turn. When +/// that turn finishes this hook runs again on the fixed change (fix → review +/// → fix), capped by `AppState.maxReviewFixReprompts` so a never-passing +/// review can't loop the agent forever. Once the cap is hit it stops +/// auto-fixing and leaves the feedback for the user to act on manually. /// - No verdict marker (a cancelled/interrupted review, or a reply missing the -/// marker) → records not-passed but does NOT re-prompt, so a manually -/// cancelled review never kicks off an auto-retry turn. +/// marker) → records not-passed and surfaces the partial reply, same as FAIL. /// /// Runs on `.afterSessionStop` (after the thread is finalized/saved). Registered /// last so its (possibly long) work doesn't delay the response notification. @@ -26,8 +30,6 @@ final class CodeReviewHook: Hook { private static let marker = "REVIEW_RESULT:" /// Upper bound on how long to wait for the review thread's first response. private static let reviewTimeout: TimeInterval = 600 - /// Max failed-review re-prompts per session before giving up. - private static let maxReviewRounds = 3 func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { // Only review clean completions; errored/cancelled turns aren't reviewable. @@ -59,6 +61,54 @@ final class CodeReviewHook: Hook { return .ignored } + // Debounce before reviewing: show a countdown card and wait. Sending a + // new follow-up message in this thread (or tapping Stop Review / the + // "Stop Code Review" context-menu item) cancels; "Start it now" skips the + // wait. `parentKey` is the resolved session id — the same key the + // scheduler is driven by from `sendPrompt`, the countdown buttons, and + // the context menu, and the `parentThreadId` stamped on the review thread. + let parentKey = payload.sessionId + + // If a newer user turn already superseded this completed turn — the user + // sent a follow-up while this review was still queued behind earlier + // after-stop hooks, so the `.newMessage` cancel arrived before the gate + // existed and no-op'd — skip the review entirely. This check sits + // immediately before the countdown card is inserted and the gate is + // registered; there is no suspension point between here and gate + // registration, so a follow-up is either visible now or arrives once the + // gate exists (where the scheduler's cancel handles it). Leave the + // verdict not-passed so a paired commit hook holds off on the stale turn. + if controller.threadHasNewerActivity(sessionId: parentKey) { + recordVerdict(false, payload: payload, controller: controller) + return .ignored + } + + let delay = controller.reviewCountdownSeconds + let countdownCard = controller.insertCard( + sessionKey: payload.sessionKey, + toolName: ReviewCountdownCard.toolName, + input: [ + ReviewCountdownCard.parentSessionKey: .string(parentKey), + ReviewCountdownCard.startAtKey: .number(Date().timeIntervalSince1970 + delay), + ] + ) + let proceed = await controller.awaitReviewGate(parentSessionKey: parentKey, delaySeconds: delay) + guard proceed else { + // Cancelled before it started. Leave the verdict not-passed so a + // paired commit hook holds off, matching the "no review ran" state. + controller.completeCard( + countdownCard, sessionKey: payload.sessionKey, + result: Self.cancellationText(reason: controller.reviewCancelReason(parentSessionKey: parentKey), started: false), + isError: true + ) + recordVerdict(false, payload: payload, controller: controller) + return .ignored + } + controller.completeCard( + countdownCard, sessionKey: payload.sessionKey, + result: "Starting code review…", isError: false + ) + let task = controller.firstUserPrompt(sessionId: payload.sessionKey) ?? "(task unknown)" let selection = controller.resolveAgentModelSelection( storedModel: hook.codeReview?.model, @@ -93,6 +143,9 @@ final class CodeReviewHook: Hook { ) logger.debug("[Hook] spawning code-review thread for session \(payload.sessionId, privacy: .public) files=\(changedFiles.count)") + // Mark running so a follow-up message / Stop action can interrupt the + // in-flight review thread; cleared once the spawn returns. + controller.markReviewRunning(parentSessionKey: parentKey) let result = await controller.spawnLinkedThread( projectId: payload.project.id, parentThreadId: payload.sessionId, @@ -102,6 +155,18 @@ final class CodeReviewHook: Hook { prompt: prompt, timeoutSeconds: Self.reviewTimeout ) + controller.clearReviewRunning(parentSessionKey: parentKey) + + // If we interrupted the review (new message / Stop), don't report it as a + // failed or verdict-less review — surface it as cancelled and leave the + // verdict not-passed so a paired commit hook holds off. + if controller.reviewWasStopped(parentSessionKey: parentKey) { + finishCard(card, hook: hook, payload: payload, controller: controller, + result: Self.cancellationText(reason: controller.reviewCancelReason(parentSessionKey: parentKey), started: true), + isError: true) + recordVerdict(false, payload: payload, controller: controller) + return .ignored + } guard let result, result.error == nil else { // Couldn't run the review (transport/agent error or timeout). Don't @@ -115,11 +180,15 @@ final class CodeReviewHook: Hook { } let verdict = parseVerdict(result.assistantText) - // Fold the review thread's full activity into the card so it can be - // expanded inline on the parent thread (and on paired mobile devices, - // since hook cards sync automatically). - let transcript = controller.threadTranscript(sessionId: result.threadId) - let body = transcript.isEmpty ? result.assistantText : transcript + // Fold the reviewer's *final* message into the card so it can be expanded + // inline on the parent thread (and on paired mobile devices, since hook + // cards sync automatically). Use the final assistant text — the same + // self-contained verdict that's fed into any auto-fix turn — rather than + // the whole thread transcript, so the card and the fix turn show exactly + // the same prose (no intermediate review chatter, no tool-call names). + let body = result.assistantText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? controller.threadTranscript(sessionId: result.threadId) + : result.assistantText let reviewLink = "Review thread: \(result.threadId)" switch verdict { @@ -127,49 +196,44 @@ final class CodeReviewHook: Hook { finishCard(card, hook: hook, payload: payload, controller: controller, result: "✅ Code review passed.\n\(reviewLink)\n\n\(body)", isError: false) recordVerdict(true, payload: payload, controller: controller) - controller.setReviewRound(0, sessionId: payload.sessionId) + // The change is good — clear the fix-round counter so the next change + // on this thread gets the full auto-fix budget again. + controller.setReviewRound(0, sessionId: payload.sessionKey) return .proceed - case .fail(let notes): + case .fail: + // The reviewer requested changes. Record not-passed (so a paired + // commit hook holds off), surface the suggestions inline on the + // parent thread via the hook card, AND feed the reviewer's feedback + // back into the reviewed thread as a bounded auto-continue fix turn. + // When that fix turn finishes, this hook runs again on the fixed + // change (fix → review → fix), capped by `maxReviewFixReprompts`. recordVerdict(false, payload: payload, controller: controller) - let round = controller.reviewRound(sessionId: payload.sessionId) - if round + 1 >= Self.maxReviewRounds { - // Give up re-prompting after the cap so a perpetually-failing - // review can't loop the agent forever. - controller.setReviewRound(0, sessionId: payload.sessionId) - finishCard(card, hook: hook, payload: payload, controller: controller, - result: "⚠️ Code review still requesting changes after \(Self.maxReviewRounds) attempts — stopping.\n\(reviewLink)\n\n\(body)", - isError: true) - return .proceed + let attempt = controller.repromptThreadAfterReviewFailure( + feedback: result.assistantText, + project: payload.project, + sessionKey: payload.sessionKey + ) + let status: String + if let attempt { + status = "⚠️ Code review requested changes — sent back to the thread to fix (attempt \(attempt) of \(AppState.maxReviewFixReprompts))." + } else { + status = "⚠️ Code review still requesting changes after \(AppState.maxReviewFixReprompts) fix attempts — stopping automatic fixes. Review the feedback and continue manually." } - - controller.setReviewRound(round + 1, sessionId: payload.sessionId) finishCard(card, hook: hook, payload: payload, controller: controller, - result: "⚠️ Code review requested changes (sent back to the agent, attempt \(round + 1) of \(Self.maxReviewRounds)).\n\(reviewLink)\n\n\(body)", + result: "\(status)\n\(reviewLink)\n\n\(body)", isError: true) - // After-stop can't auto-continue, so re-prompt the thread directly. - controller.sendThreadMessage( - sessionId: payload.sessionId, - prompt: """ - A Code Review of your change requested changes. Address the feedback below, then finish. - - \(notes) - """ - ) return .proceed case .unknown: // The reviewer ended without a PASS/FAIL marker. The dominant cause // is a review thread the user manually cancelled (or one that was - // interrupted) — its partial reply has no verdict. Don't auto-retry: - // record not-passed (so a paired commit hook still holds off) and - // finish the card, but leave the agent alone. A genuine "reviewer - // forgot the marker" is rare and is better surfaced quietly here than - // by silently kicking off an unwanted fix turn. + // interrupted) — its partial reply has no verdict. Record not-passed + // (so a paired commit hook still holds off) and surface whatever + // partial reply we have, but leave the agent alone. recordVerdict(false, payload: payload, controller: controller) - controller.setReviewRound(0, sessionId: payload.sessionId) finishCard(card, hook: hook, payload: payload, controller: controller, - result: "⚠️ Code review ended without a verdict (it may have been cancelled or interrupted) — not retrying.\n\(reviewLink)\n\n\(body)", + result: "⚠️ Code review ended without a verdict (it may have been cancelled or interrupted).\n\(reviewLink)\n\n\(body)", isError: true) return .ignored } @@ -199,6 +263,19 @@ final class CodeReviewHook: Hook { // MARK: - Helpers + /// Copy for a cancelled/stopped review card. `started` distinguishes a review + /// stopped mid-run from one cancelled during the countdown; `reason` avoids + /// mislabeling an explicit Stop (button / menu) as "a new message was sent". + private static func cancellationText(reason: ReviewCancelReason?, started: Bool) -> String { + let verb = started ? "stopped" : "cancelled" + switch reason { + case .newMessage: + return "Code review \(verb) — a new message was sent." + case .stopButton, .contextMenu, .none: + return "Code review \(verb)." + } + } + private func recordVerdict(_ passed: Bool, payload: SessionEndPayload, controller: any HookController) { // Store under both the in-memory key and the resolved id; the commit hook // reads whichever it sees first. @@ -243,11 +320,23 @@ final class CodeReviewHook: Hook { ## What to do Inspect the changed files and judge whether the change correctly and safely accomplishes the task. Look for bugs, missed requirements, regressions, and obvious quality problems. + ## How to write your final message + Your final message is the ONLY thing handed back to the agent that made the + change — it cannot see your tool calls, intermediate notes, or the files you + read. So make that final message complete and self-contained: + - Summarize what you reviewed and your overall judgment in a sentence or two. + - For every issue, give the file and line (e.g. `Foo.swift:42`), explain what + is wrong and why it matters, and state concretely how to fix it. + - Quote the relevant snippet when it makes the fix unambiguous. + - Order issues from most to least important. Don't reference "see above" or a + tool call — restate everything the agent needs inline. + End your reply with a single line — exactly one of: `\(Self.marker) PASS` (the change is good as-is) `\(Self.marker) FAIL` (changes are needed) - If you FAIL the review, list the specific, actionable issues to fix above that line. + If you FAIL the review, put the full, self-contained list of actionable issues + above that line. """ if let extra = extraInstructions?.trimmingCharacters(in: .whitespacesAndNewlines), !extra.isEmpty { prompt += "\n\n## Additional instructions\n\(extra)" diff --git a/RxCode/Services/Hooks/hooks/SendMessageHook.swift b/RxCode/Services/Hooks/hooks/SendMessageHook.swift new file mode 100644 index 00000000..e1b76c02 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/SendMessageHook.swift @@ -0,0 +1,140 @@ +import Foundation +import os +import RxCodeCore + +/// Built-in `.sendMessage` hook. After a clean session stop, if the project has +/// an enabled Send Message hook, it sends a fixed message to the assistant — +/// either as a follow-up turn in the same thread or into a new linked child +/// thread — optionally gated on a model-evaluated condition. +/// +/// Once-per-session: the hook marks the session via `HookSetupKind.sendMessage` +/// when it fires and short-circuits while that marker is present. Unlike the +/// commit hook, the marker is *not* self-cleared; it persists until the user +/// sends a genuine new message (reset in `AppState.sendPrompt`). That both +/// prevents the same-thread injected turn from looping and stops the hook +/// re-firing on later automated turns within the same session. +/// +/// Runs on `.afterSessionStop`. +@MainActor +final class SendMessageHook: Hook { + let hookID = "builtin.sendMessage" + private let logger = Logger(subsystem: "com.claudework", category: "SendMessageHook") + /// Upper bound on how long to wait for a spawned thread's first response. + private static let newThreadTimeout: TimeInterval = 120 + + func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { + // Once-per-session guard FIRST: if we already fired for this session, do + // nothing until the user resets it with a new message. + if controller.isSetupSession(kind: HookSetupKind.sendMessage, sessionKey: payload.sessionKey) { + return .ignored + } + + let hooks = await controller.enabledHookProfiles(projectId: payload.project.id, trigger: .afterSessionStop) + .filter { $0.action == .sendMessage } + guard let hook = hooks.first, let cfg = hook.sendMessage else { return .ignored } + + let message = cfg.message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { return .ignored } + + // Only act on clean completions; errored/cancelled turns are skipped. + guard payload.reason == .completed, !payload.turnDidError else { return .ignored } + + // Defer while the user still has queued messages — they run as further + // turns; act once the queue drains. + if payload.hasQueuedFollowups { return .ignored } + + // Optional condition gate, evaluated by a (cheap) model. The check runs + // as a one-shot background classifier (no visible thread), so surface a + // card with the condition + verdict — otherwise a NO/error verdict skips + // the hook silently and the user can't tell it ran at all. + if cfg.conditionEnabled, + let condition = cfg.condition?.trimmingCharacters(in: .whitespacesAndNewlines), + !condition.isEmpty { + let conditionCard = controller.insertCard( + sessionKey: payload.sessionKey, + toolName: AppState.hookToolName(for: hook), + input: [ + "name": .string(hook.name), + "trigger": .string(hook.trigger.displayName), + "summary": .string("Checking condition: \(condition)"), + ] + ) + let verdict = await controller.evaluateCondition( + condition: condition, + lastAssistantText: payload.lastAssistantText, + model: cfg.conditionModel, + sessionId: payload.sessionId + ) + switch verdict { + case true: + controller.completeCard(conditionCard, sessionKey: payload.sessionKey, result: "Condition met (YES) — sending the message.", isError: false) + case false: + controller.completeCard(conditionCard, sessionKey: payload.sessionKey, result: "Condition not met (NO) — message skipped.", isError: false) + default: + controller.completeCard(conditionCard, sessionKey: payload.sessionKey, result: "Could not evaluate the condition — message skipped.", isError: true) + } + guard verdict == true else { + logger.debug("[Hook] send-message condition not met for session \(payload.sessionId, privacy: .public) verdict=\(String(describing: verdict), privacy: .public)") + return .ignored + } + } + + switch cfg.target { + case .sameThread: + let card = controller.insertCard( + sessionKey: payload.sessionKey, + toolName: AppState.hookToolName(for: hook), + input: [ + "name": .string(hook.name), + "trigger": .string(hook.trigger.displayName), + "summary": .string("Sending a follow-up message to the assistant…"), + ] + ) + controller.completeCard(card, sessionKey: payload.sessionKey, result: "Sent a follow-up message to this thread.", isError: false) + // Pass `setupKind` so the session is marked AFTER the message is + // appended (in `sendCrossProject`); marking before would let the + // injected turn clear its own marker and loop forever. + logger.debug("[Hook] sending follow-up message into session \(payload.sessionId, privacy: .public)") + controller.sendThreadMessage(sessionId: payload.sessionId, prompt: message, setupKind: HookSetupKind.sendMessage) + return .proceed + + case .newThread: + // The spawned child is a separate session that runs no hooks, so mark + // the parent now to enforce once-per-session on this thread. + controller.markSetupSession(kind: HookSetupKind.sendMessage, sessionKey: payload.sessionKey) + let selection = controller.resolveAgentModelSelection( + storedModel: cfg.newThreadModel, + fallbackSessionId: payload.sessionId + ) + let trimmedLabel = cfg.newThreadLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + let label = (trimmedLabel?.isEmpty == false) ? trimmedLabel! : "Assistant" + + let card = controller.insertCard( + sessionKey: payload.sessionKey, + toolName: AppState.hookToolName(for: hook), + input: [ + "name": .string(hook.name), + "trigger": .string(hook.trigger.displayName), + "summary": .string("Starting a new thread for the assistant…"), + ] + ) + logger.debug("[Hook] spawning linked thread from session \(payload.sessionId, privacy: .public)") + let result = await controller.spawnLinkedThread( + projectId: payload.project.id, + parentThreadId: payload.sessionId, + label: label, + agentProvider: selection?.provider, + model: selection?.model, + prompt: message, + timeoutSeconds: Self.newThreadTimeout + ) + if let result, result.error == nil { + controller.completeCard(card, sessionKey: payload.sessionKey, result: "Started thread \(result.threadId).", isError: false) + } else { + let detail = result?.error ?? "The new thread did not respond in time." + controller.completeCard(card, sessionKey: payload.sessionKey, result: "Could not start the thread: \(detail)", isError: true) + } + return .proceed + } + } +} diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift index 751f284e..f7d53e9f 100644 --- a/RxCode/Services/OpenAISummarizationService.swift +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -346,6 +346,12 @@ actor OpenAISummarizationService { """ } + /// Generic one-shot completion for an arbitrary prompt (e.g. a hook condition + /// gate). Returns the model's text, sanitized of error strings. + func generatePlainCompletion(prompt: String, endpoint: String, apiKey: String, model: String, maxTokens: Double) async -> String? { + await generateSummary(prompt: prompt, endpoint: endpoint, apiKey: apiKey, model: model, maxTokens: maxTokens) + } + private func generateSummary(prompt: String, endpoint: String, apiKey: String, model: String, maxTokens: Double) async -> String? { let body: JSONValue = .object([ "model": .string(model), diff --git a/RxCode/Services/ThreadStore.swift b/RxCode/Services/ThreadStore.swift index 407f5ec0..f0fc6cd9 100644 --- a/RxCode/Services/ThreadStore.swift +++ b/RxCode/Services/ThreadStore.swift @@ -283,6 +283,41 @@ final class ThreadStore { return ids } + /// Persist the latest code-review verdict on the thread row so the sidebar + /// review dot survives a reload. `sessionId` may be the thread's local id or + /// its resolved CLI session id — match either. No-op if no row is found. + func setReviewPassed(sessionId: String, passed: Bool) { + let row = fetch(id: sessionId) ?? fetchByCliSessionId(sessionId) + guard let row else { return } + guard row.reviewPassed != passed else { return } + row.reviewPassed = passed + save() + } + + /// Rehydrate the in-memory `reviewPassedBySession` map at launch. Keyed by + /// both the thread's local id and its CLI session id so either lookup hits. + func loadReviewVerdicts() -> [String: Bool] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.reviewPassed != nil } + ) + let rows = (try? context.fetch(descriptor)) ?? [] + var map: [String: Bool] = [:] + for row in rows { + guard let passed = row.reviewPassed else { continue } + map[row.id] = passed + if let cli = row.cliSessionId { map[cli] = passed } + } + return map + } + + private func fetchByCliSessionId(_ cliSessionId: String) -> ChatThread? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.cliSessionId == cliSessionId } + ) + descriptor.fetchLimit = 1 + return (try? context.fetch(descriptor))?.first + } + /// Set `cliSessionId` on the thread row (used when the CLI assigns a session id mid-stream). func setCliSessionId(localId: String, cliSessionId: String) { guard let row = fetch(id: localId) else { return } @@ -653,6 +688,16 @@ final class ThreadStore { return (try? context.fetch(descriptor)) ?? [] } + /// Number of distinct files this session recorded edits to. There is one row + /// per `(sessionId, path)`, so a row count equals the distinct-file count — + /// a cheap query (no content loaded) used to gate the "Commit Files" action. + func fileEditCount(sessionId: String) -> Int { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.sessionId == sessionId } + ) + return (try? context.fetchCount(descriptor)) ?? 0 + } + private func fetchFileEdit(sessionId: String, path: String) -> ThreadFileEdit? { var descriptor = FetchDescriptor( predicate: #Predicate { $0.sessionId == sessionId && $0.path == path } diff --git a/RxCode/Views/Chat/RecentChatsSuggestionList.swift b/RxCode/Views/Chat/RecentChatsSuggestionList.swift index f9403126..cf99c013 100644 --- a/RxCode/Views/Chat/RecentChatsSuggestionList.swift +++ b/RxCode/Views/Chat/RecentChatsSuggestionList.swift @@ -149,14 +149,6 @@ struct RecentChatsSuggestionList: View { Divider() - Button { - Task { _ = try? await appState.commitFilesForThread(sessionId: summary.id) } - } label: { - Label("Commit Files", systemImage: "checkmark.circle") - } - - Divider() - Button(role: .destructive) { sessionToDelete = chatSession } label: { diff --git a/RxCode/Views/Hooks/HookConfigurationsView.swift b/RxCode/Views/Hooks/HookConfigurationsView.swift index 45623193..ae68dc75 100644 --- a/RxCode/Views/Hooks/HookConfigurationsView.swift +++ b/RxCode/Views/Hooks/HookConfigurationsView.swift @@ -143,6 +143,7 @@ struct HookConfigurationsView: View { switch hook.action { case .codeReview: return "checkmark.seal.fill" case .commitPush: return "arrow.up.circle.fill" + case .sendMessage: return "paperplane.fill" case .command: switch hook.type { case .xcode: return "hammer.fill" @@ -174,6 +175,11 @@ struct HookConfigurationsView: View { } label: { Label("Commit & Push", systemImage: "arrow.up.circle.fill") } + Button { + addHook(action: .sendMessage, trigger: HookAction.sendMessage.fixedTrigger ?? .afterSessionStop) + } label: { + Label("Send Message", systemImage: "paperplane.fill") + } } label: { Image(systemName: "plus") } @@ -191,13 +197,15 @@ struct HookConfigurationsView: View { case .command: defaultName = "New Hook" case .codeReview: defaultName = "Code Review" case .commitPush: defaultName = "Commit & Push" + case .sendMessage: defaultName = "Send Message" } let new = HookProfile( projectId: project.id, name: defaultName, trigger: action.fixedTrigger ?? trigger, action: action, - codeReview: action == .codeReview ? CodeReviewConfig() : nil + codeReview: action == .codeReview ? CodeReviewConfig() : nil, + sendMessage: action == .sendMessage ? SendMessageConfig() : nil ) draft.append(new) selectedId = new.id diff --git a/RxCode/Views/Hooks/HookContextMenuItems.swift b/RxCode/Views/Hooks/HookContextMenuItems.swift index f503202b..ec9b9efe 100644 --- a/RxCode/Views/Hooks/HookContextMenuItems.swift +++ b/RxCode/Views/Hooks/HookContextMenuItems.swift @@ -1,20 +1,17 @@ import RxCodeCore import SwiftUI +/// Renders hook-supplied serializable `MenuItem`s as context-menu content, +/// wiring taps to the desktop menu action handler so commands dispatch locally +/// and deep links open their sheet. Mobile renders the same `MenuItem`s with its +/// own handler. struct HookContextMenuItems: View { - let items: [HookMenuItem] + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + let items: [MenuItem] var body: some View { - ForEach(items) { item in - Button(role: item.role) { - item.action() - } label: { - Label { - Text(item.title) - } icon: { - Image(systemName: item.systemImage) - } - } - } + MenuItemsView(items) + .menuActionHandler(appState.desktopMenuActionHandler(navigatingIn: windowState)) } } diff --git a/RxCode/Views/Hooks/HookProfileDetailForm.swift b/RxCode/Views/Hooks/HookProfileDetailForm.swift index dede1687..a38fff88 100644 --- a/RxCode/Views/Hooks/HookProfileDetailForm.swift +++ b/RxCode/Views/Hooks/HookProfileDetailForm.swift @@ -26,6 +26,9 @@ struct HookProfileDetailForm: View { if newAction == .codeReview, hook.codeReview == nil { hook.codeReview = CodeReviewConfig() } + if newAction == .sendMessage, hook.sendMessage == nil { + hook.sendMessage = SendMessageConfig() + } } Picker("Trigger", selection: $hook.trigger) { ForEach(HookTrigger.allCases, id: \.self) { trigger in @@ -63,6 +66,8 @@ struct HookProfileDetailForm: View { codeReviewSection case .commitPush: commitPushSection + case .sendMessage: + sendMessageSection } } .formStyle(.grouped) @@ -107,6 +112,146 @@ struct HookProfileDetailForm: View { } } + private var sendMessageSection: some View { + Section { + VStack(alignment: .leading, spacing: 4) { + Text("Message") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + TextEditor(text: sendMessageMessageBinding) + .font(.system(size: 12, design: .monospaced)) + .frame(minHeight: 70) + } + Picker("Send to", selection: sendMessageTargetBinding) { + Text("Same thread").tag(SendMessageTarget.sameThread) + Text("New thread").tag(SendMessageTarget.newThread) + } + if sendMessageTargetBinding.wrappedValue == .newThread { + TextField("New thread label", text: sendMessageNewThreadLabelBinding, prompt: Text("Assistant")) + Picker("Thread model", selection: sendMessageNewThreadModelBinding) { + Text("Same as thread").tag("") + ForEach(appState.availableAgentModelSections(), id: \.id) { section in + Section(section.title) { + ForEach(section.models, id: \.key) { model in + Text(model.displayName).tag(model.key) + } + } + } + } + } + Toggle("Only send when a condition is met", isOn: sendMessageConditionEnabledBinding) + if sendMessageConditionEnabledBinding.wrappedValue { + VStack(alignment: .leading, spacing: 4) { + Text("Condition") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + TextEditor(text: sendMessageConditionBinding) + .font(.system(size: 12, design: .monospaced)) + .frame(minHeight: 60) + } + Picker("Condition model", selection: sendMessageConditionModelBinding) { + Text("Summarization model").tag("") + ForEach(appState.availableAgentModelSections(), id: \.id) { section in + Section(section.title) { + ForEach(section.models, id: \.key) { model in + Text(model.displayName).tag(model.key) + } + } + } + } + } + } header: { + Text("Send Message") + } footer: { + Text("After the session is finalized, your message is sent to the assistant — as a follow-up in this thread or into a new linked thread that runs no hooks. With a condition, a model first decides yes/no. Fires once per session and resets when you send a new message.") + } + } + + // MARK: - Send Message bindings + + private var sendMessageMessageBinding: Binding { + Binding( + get: { hook.sendMessage?.message ?? "" }, + set: { newValue in + var cfg = hook.sendMessage ?? SendMessageConfig() + cfg.message = newValue + hook.sendMessage = cfg + } + ) + } + + private var sendMessageTargetBinding: Binding { + Binding( + get: { hook.sendMessage?.target ?? .sameThread }, + set: { newValue in + var cfg = hook.sendMessage ?? SendMessageConfig() + cfg.target = newValue + hook.sendMessage = cfg + } + ) + } + + private var sendMessageNewThreadLabelBinding: Binding { + Binding( + get: { hook.sendMessage?.newThreadLabel ?? "" }, + set: { newValue in + var cfg = hook.sendMessage ?? SendMessageConfig() + cfg.newThreadLabel = newValue.isEmpty ? nil : newValue + hook.sendMessage = cfg + } + ) + } + + private var sendMessageNewThreadModelBinding: Binding { + Binding( + get: { normalizedModelTag(hook.sendMessage?.newThreadModel ?? "") }, + set: { newValue in + var cfg = hook.sendMessage ?? SendMessageConfig() + cfg.newThreadModel = newValue.isEmpty ? nil : newValue + hook.sendMessage = cfg + } + ) + } + + private var sendMessageConditionEnabledBinding: Binding { + Binding( + get: { hook.sendMessage?.conditionEnabled ?? false }, + set: { newValue in + var cfg = hook.sendMessage ?? SendMessageConfig() + cfg.conditionEnabled = newValue + hook.sendMessage = cfg + } + ) + } + + private var sendMessageConditionBinding: Binding { + Binding( + get: { hook.sendMessage?.condition ?? "" }, + set: { newValue in + var cfg = hook.sendMessage ?? SendMessageConfig() + cfg.condition = newValue.isEmpty ? nil : newValue + hook.sendMessage = cfg + } + ) + } + + private var sendMessageConditionModelBinding: Binding { + Binding( + get: { normalizedModelTag(hook.sendMessage?.conditionModel ?? "") }, + set: { newValue in + var cfg = hook.sendMessage ?? SendMessageConfig() + cfg.conditionModel = newValue.isEmpty ? nil : newValue + hook.sendMessage = cfg + } + ) + } + + /// Map a stored model key/id to the picker tag (`""` ⇒ default). Shared by the + /// code-review and send-message model pickers. + private func normalizedModelTag(_ storedValue: String) -> String { + normalizedCodeReviewModelTag(storedValue) + } + private var codeReviewModelBinding: Binding { Binding( get: { normalizedCodeReviewModelTag(hook.codeReview?.model ?? "") }, @@ -149,6 +294,8 @@ struct HookProfileDetailForm: View { return "Code Review runs after the session is finalized; a failing review sends the agent back to work and re-reviews." case .commitPush: return "Commit & Push runs after the session is finalized and saved." + case .sendMessage: + return "Send Message runs after the session is finalized. It fires once per session and resets when you send a new message." case .command: switch hook.trigger { case .beforeSessionStart: diff --git a/RxCode/Views/Hooks/HookProgressOverlay.swift b/RxCode/Views/Hooks/HookProgressOverlay.swift index 06c1cd88..cb28edbd 100644 --- a/RxCode/Views/Hooks/HookProgressOverlay.swift +++ b/RxCode/Views/Hooks/HookProgressOverlay.swift @@ -39,6 +39,18 @@ struct HookUIModifier: ViewModifier { Text(detail) } } + .alert( + "Command Failed", + isPresented: Binding( + get: { appState.hookErrorMessage != nil }, + set: { if !$0 { appState.hookErrorMessage = nil } } + ), + presenting: appState.hookErrorMessage + ) { _ in + Button("OK", role: .cancel) { appState.hookErrorMessage = nil } + } message: { message in + Text(message) + } } } diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index a22f0ae1..fc9025fd 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -506,12 +506,6 @@ struct ProjectTabButton: View { HookContextMenuItems(items: hookItems) Divider() } - Button { - Task { _ = try? await appState.commitAllChangesForProject(project: project) } - } label: { - Label("Commit All Changes", systemImage: "checkmark.circle") - } - Divider() Button { renameText = project.name projectToRename = project diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index 72f2c2b5..0ebf2e11 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -157,6 +157,7 @@ struct BriefingView: View { .background(ClaudeTheme.background) .task(id: projectPathsKey) { await refreshCurrentBranches() + await appState.refreshProjectGitDirty() } .onAppear { AnalyticsService.shared.log(.briefingListOpened) @@ -680,22 +681,18 @@ struct BriefingView: View { Divider() - Button { - startCodeReview(for: group, project: project) - } label: { - Label("Code Review for \(group.branch)", systemImage: "checklist") - } - - Button { - startCommitAll(for: project) - } label: { - Label("Commit All Changes", systemImage: "checkmark.circle") + let handler = appState.desktopMenuActionHandler { threadId in + appState.selectSession(id: threadId, in: windowState) } - let hookItems = appState.projectContextMenuItems(for: project) - if !hookItems.isEmpty { - Divider() - HookContextMenuItems(items: hookItems) + // The briefing card represents one branch, so it asks the hooks for a + // branch-scoped menu: Code Review / Create PR target `group.branch`, + // followed by the project's autopilot setup items. All hook-built — no + // items assembled in the view. + let items = appState.projectContextMenuItems(for: project, branch: group.branch) + if !items.isEmpty { + MenuItemsView(items) + .menuActionHandler(handler) } if let url = gitHubURL(for: group, project: project) { diff --git a/RxCode/Views/Sidebar/HistoryListView.swift b/RxCode/Views/Sidebar/HistoryListView.swift index acf0b64b..f71baf45 100644 --- a/RxCode/Views/Sidebar/HistoryListView.swift +++ b/RxCode/Views/Sidebar/HistoryListView.swift @@ -224,14 +224,6 @@ struct HistoryListView: View { Divider() - Button { - Task { _ = try? await appState.commitFilesForThread(sessionId: summary.id) } - } label: { - Label("Commit Files", systemImage: "checkmark.circle") - } - - Divider() - Button(role: .destructive) { sessionToDelete = chatSession } label: { diff --git a/RxCode/Views/Sidebar/ProjectChatRow.swift b/RxCode/Views/Sidebar/ProjectChatRow.swift index f416321c..268a288c 100644 --- a/RxCode/Views/Sidebar/ProjectChatRow.swift +++ b/RxCode/Views/Sidebar/ProjectChatRow.swift @@ -96,7 +96,10 @@ struct ProjectChatRow: View { let onDelete: () -> Void let onCodeReview: () -> Void let onCommitFiles: () -> Void - let hookMenuItems: [HookMenuItem] + /// Serializable action items (code review, commit, autopilot setup) supplied + /// by hooks. Rendered via `MenuItemsView`; taps route through the desktop + /// menu action handler. + let hookMenuItems: [MenuItem] /// Nesting depth; review children render one level in from their parent. var indentLevel: Int = 0 /// Replaces the thread title (e.g. `"Review 1"` for a nested review child). @@ -108,9 +111,21 @@ struct ProjectChatRow: View { /// Latest code-review verdict for this thread: `true` passed, `false` found /// issues, `nil` not reviewed (no icon shown). var reviewPassed: Bool? = nil + /// Whether to offer "Commit Files" — hidden when the thread recorded no file + /// edits (nothing to commit). + var canCommitFiles: Bool = true + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState @State private var isHovered = false + /// True when this row *is* a `[Code Review]` thread (manual or hook-spawned). + /// Such threads hide the "Code Review" / "Commit Files" actions since you + /// don't review or commit a review thread itself. + private var isCodeReviewThread: Bool { + summary.threadLabel == AppState.manualCodeReviewLabel + } + private var isActiveStatus: Bool { switch status { case .awaitingPermission, .done, .error: return true @@ -143,6 +158,8 @@ struct ProjectChatRow: View { .lineLimit(1) .truncationMode(.tail) + Spacer(minLength: 4) + if showLabelChip, let label = summary.threadLabel, !label.isEmpty { Text(label) .font(.system(size: ClaudeTheme.size(9), weight: .semibold)) @@ -153,8 +170,6 @@ struct ProjectChatRow: View { .fixedSize() } - Spacer(minLength: 4) - if let reviewPassed { reviewVerdictIcon(passed: reviewPassed) } @@ -212,16 +227,13 @@ struct ProjectChatRow: View { Label("Archive", systemImage: "archivebox") } } - Divider() - Button { onCodeReview() } label: { - Label("Code Review for this thread", systemImage: "checklist") - } - Button { onCommitFiles() } label: { - Label("Commit Files", systemImage: "checkmark.circle") - } + // Code review / commit / autopilot actions now come from hooks as + // serializable MenuItems (gated for review threads and file changes + // inside the hooks). The handler dispatches taps locally on desktop. if !hookMenuItems.isEmpty { Divider() - HookContextMenuItems(items: hookMenuItems) + MenuItemsView(hookMenuItems) + .menuActionHandler(appState.desktopMenuActionHandler(navigatingIn: windowState)) } Divider() Button(role: .destructive) { onDelete() } label: { diff --git a/RxCode/Views/Sidebar/ProjectListView.swift b/RxCode/Views/Sidebar/ProjectListView.swift index d39b4264..fd676291 100644 --- a/RxCode/Views/Sidebar/ProjectListView.swift +++ b/RxCode/Views/Sidebar/ProjectListView.swift @@ -54,27 +54,15 @@ struct ProjectListView: View { projectRow(project) .tag(project.id) .contextMenu { - if canCreatePR(project) { - Button { startCreatePR(project) } label: { - Label( - creatingPRProjectId == project.id ? "Creating Pull Request…" : "Create Pull Request", - systemImage: "arrow.triangle.pull" - ) - } - .disabled(creatingPRProjectId == project.id) - Divider() - } + // Create PR, commit, code review, and autopilot setup all + // come from hooks as serializable MenuItems now; taps + // dispatch locally through the desktop menu handler. let hookItems = appState.projectContextMenuItems(for: project) if !hookItems.isEmpty { - HookContextMenuItems(items: hookItems) + MenuItemsView(hookItems) + .menuActionHandler(appState.desktopMenuActionHandler(navigatingIn: windowState)) Divider() } - Button { - Task { _ = try? await appState.commitAllChangesForProject(project: project) } - } label: { - Label("Commit All Changes", systemImage: "checkmark.circle") - } - Divider() Button { renameText = project.name projectToRename = project diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index ee6bbfe8..6d9dcef1 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -24,6 +24,12 @@ struct ProjectTreeView: View { @State private var showAllChatsSheet = false + /// Identity for the dirty-flag refresh task — changes whenever a project is + /// added/removed or its path changes, re-running `refreshProjectGitDirty`. + private var gitDirtyRefreshKey: String { + appState.projects.map { "\($0.id.uuidString):\($0.path)" }.joined(separator: "|") + } + var body: some View { VStack(alignment: .leading, spacing: 0) { SummarySidebarSection() @@ -42,6 +48,17 @@ struct ProjectTreeView: View { projectList } } + // Keep the per-project dirty flag fresh so "Commit All Changes" hides on + // clean projects. Recompute when the project set changes and whenever a + // turn finishes (a commit/agent run may have cleaned the tree). + .task(id: gitDirtyRefreshKey) { + await appState.refreshProjectGitDirty() + } + .onChange(of: appState.isStreaming(in: windowState)) { old, new in + if old && !new { + Task { await appState.refreshProjectGitDirty() } + } + } .alert("Rename Project", isPresented: Binding( get: { renameProject != nil }, set: { if !$0 { renameProject = nil } } @@ -318,6 +335,7 @@ struct ProjectTreeView: View { private struct ProjectTreeRow: View { @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState let project: Project @Binding var isExpanded: Bool @@ -329,7 +347,7 @@ private struct ProjectTreeRow: View { let onNewChat: () -> Void let onCodeReview: () -> Void let onCommitAll: () -> Void - let hookMenuItems: [HookMenuItem] + let hookMenuItems: [MenuItem] @State private var isHovered = false @State private var showLocationPopover = false @@ -474,22 +492,13 @@ private struct ProjectTreeRow: View { Button { onOpenInNewWindow() } label: { Label("Open in New Window", systemImage: "macwindow.badge.plus") } - Button { onCodeReview() } label: { - Label("Code Review for Current Branch", systemImage: "checklist") - } - Button { onCommitAll() } label: { - Label("Commit All Changes", systemImage: "checkmark.circle") - } - if canCreatePR { - Button { startCreatePR() } label: { - Label(creatingPR ? "Creating Pull Request…" : "Create Pull Request", - systemImage: "arrow.triangle.pull") - } - .disabled(creatingPR) - } + // Code review, commit, create PR, and the autopilot setup actions now + // come from hooks as serializable MenuItems (gated inside the hooks), so + // the desktop and mobile render the same set. Taps dispatch locally here. if !hookMenuItems.isEmpty { Divider() - HookContextMenuItems(items: hookMenuItems) + MenuItemsView(hookMenuItems) + .menuActionHandler(appState.desktopMenuActionHandler(navigatingIn: windowState)) } Divider() Button { onRename() } label: { @@ -631,12 +640,12 @@ private struct ProjectChatsList: View { chatRow(for: summary, reviewDisclosure: disclosure(for: summary, children: children)) if isExpanded { - ForEach(Array(children.enumerated()), id: \.element.id) { index, child in + ForEach(childDisplayItems(children), id: \.summary.id) { item in chatRow( - for: child, + for: item.summary, indentLevel: 1, - titleOverride: "Review \(index + 1)", - showLabelChip: false + titleOverride: item.titleOverride, + showLabelChip: item.showLabelChip ) .transition(.asymmetric( insertion: .move(edge: .top).combined(with: .opacity), @@ -647,6 +656,28 @@ private struct ProjectChatsList: View { } } + /// How a nested child row should render. Code-review children keep the + /// `Review 1`, `Review 2`, … numbering (the nesting + number conveys what + /// they are, so no chip). Other linked children — e.g. a Send Message hook's + /// new thread — show their own title and keep their `threadLabel` chip so the + /// user-chosen label is visible instead of a bogus "Review N". + private struct ChildDisplayItem { + let summary: ChatSession.Summary + let titleOverride: String? + let showLabelChip: Bool + } + + private func childDisplayItems(_ children: [ChatSession.Summary]) -> [ChildDisplayItem] { + var reviewNumber = 0 + return children.map { child in + if child.threadLabel == AppState.manualCodeReviewLabel { + reviewNumber += 1 + return ChildDisplayItem(summary: child, titleOverride: "Review \(reviewNumber)", showLabelChip: false) + } + return ChildDisplayItem(summary: child, titleOverride: nil, showLabelChip: true) + } + } + private func disclosure( for summary: ChatSession.Summary, children: [ChatSession.Summary] @@ -758,7 +789,8 @@ private struct ProjectChatsList: View { titleOverride: titleOverride, showLabelChip: showLabelChip, reviewDisclosure: reviewDisclosure, - reviewPassed: appState.reviewPassedBySession[sessionId] + reviewPassed: appState.reviewPassedBySession[sessionId], + canCommitFiles: appState.threadHasFileChanges(sessionId: sessionId) ) } } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt index 22765a94..fcb0c469 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt @@ -111,6 +111,17 @@ enum class AutopilotOp(val wire: String) { THREAD_CREATE_CODE_REVIEW("threadCreateCodeReview"), PROJECT_COMMIT_ALL("projectCommitAll"), THREAD_COMMIT_FILES("threadCommitFiles"), + // Fix failing CI (desktop-mediated): spawn a thread seeded with the failing + // GitHub Actions run(s) for the branch and ask the agent to fix it. + PROJECT_FIX_CI("projectFixCI"), + + // Serializable context menus. The desktop builds the same `[MenuItem]` its + // own context menus render (from hooks) and ships it as JSON; Android renders + // the identical items. `menuExecuteCommand` runs a tapped `MenuActionCommand` + // through the desktop's shared dispatcher. + MENU_FOR_PROJECT("menuForProject"), + MENU_FOR_THREAD("menuForThread"), + MENU_EXECUTE_COMMAND("menuExecuteCommand"), // Global search — one call returns thread matches AND published-docs matches. SEARCH_THREADS_AND_DOCS("searchThreadsAndDocs"), @@ -260,6 +271,41 @@ data class AutopilotProjectSecretsWriteBody( data class Plaintext(val filename: String, val content: String) } +// MARK: - Serializable context menus + +/** + * Mobile → desktop: request the serialized project menu. `branch` scopes it to a + * briefing card's branch (Code Review / Create PR target that branch); null + * returns the generic project menu addressing the current branch. + */ +@Serializable +data class AutopilotProjectMenuBody( + @Serializable(with = UuidSerializer::class) val projectId: UUID, + val branch: String? = null, +) + +/** + * Desktop → mobile: the serialized context-menu items for a project or thread, + * built from the same hooks the desktop renders. Android decodes and renders the + * identical [MenuItem]s. + */ +@Serializable +data class AutopilotMenuResult(val items: List) + +/** Mobile → desktop: a [MenuActionCommand] the user tapped, run on the Mac. */ +@Serializable +data class AutopilotExecuteCommandBody(val command: MenuActionCommand) + +/** + * Desktop → mobile: the outcome of executing a menu command — a thread to + * navigate to and/or a URL (e.g. a created pull request) to open on the phone. + */ +@Serializable +data class AutopilotExecuteCommandResult( + val threadId: String? = null, + val openURL: String? = null, +) + // MARK: - Global search body /** A single query run across both on-device threads and published docs. */ diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/MenuModels.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/MenuModels.kt new file mode 100644 index 00000000..ad162086 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/MenuModels.kt @@ -0,0 +1,141 @@ +package app.rxlab.rxcode.proto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.util.UUID + +/** + * Kotlin mirror of `RxCodeCore/Menu/MenuItem.swift`. The desktop is the single + * source of truth for context menus: it builds `[MenuItem]` from its hooks and + * ships the tree as JSON over the autopilot relay (`menuForProject` / + * `menuForThread`). Android decodes and renders the identical items, and runs a + * tapped command back on the Mac via `menuExecuteCommand` — exactly like iOS, + * rather than re-deriving the menu natively. + * + * The wire shapes below match Swift's `JSONEncoder` output verbatim (verified + * against the encoder), including the enum-with-associated-values encoding for + * [MenuAction] (`{"command":{"_0":{…}}}` / `{"deepLink":{"_0":"url"}}`). + */ + +/** Whether a row is a tappable item or a visual separator. */ +@Serializable +enum class MenuItemKind { + @SerialName("item") ITEM, + @SerialName("separator") SEPARATOR, +} + +/** Mirrors Swift's `MenuItemRole` (only the roles a menu needs). */ +@Serializable +enum class MenuItemRole { + @SerialName("destructive") DESTRUCTIVE, +} + +/** Selects which operation a `.command` action performs. */ +@Serializable +enum class MenuCommandKind { + @SerialName("projectCodeReview") PROJECT_CODE_REVIEW, + @SerialName("projectCommitAll") PROJECT_COMMIT_ALL, + @SerialName("projectCreatePullRequest") PROJECT_CREATE_PULL_REQUEST, + @SerialName("projectFixCI") PROJECT_FIX_CI, + @SerialName("threadCodeReview") THREAD_CODE_REVIEW, + @SerialName("threadCommitFiles") THREAD_COMMIT_FILES, + @SerialName("threadStopCodeReview") THREAD_STOP_CODE_REVIEW, +} + +/** + * A serializable description of work for the desktop to perform. `isAsync` marks + * a command that blocks on long-running work (e.g. opening a pull request); the + * UI shows a loading dialog and waits for it. The desktop declares the flag once + * and every platform honors it. + */ +@Serializable +data class MenuActionCommand( + val kind: MenuCommandKind, + @Serializable(with = UuidSerializer::class) val projectId: UUID? = null, + val sessionId: String? = null, + val branch: String? = null, + val isAsync: Boolean = false, +) + +/** + * What a tapped item does: run a named command on the Mac, or open a deep link + * that presents platform-local UI (a sheet / setup chat) on whichever side + * renders it. Custom-serialized to match Swift's enum encoding. + */ +@Serializable(with = MenuActionSerializer::class) +sealed interface MenuAction { + data class Command(val command: MenuActionCommand) : MenuAction + data class DeepLink(val url: String) : MenuAction +} + +/** + * Encodes/decodes [MenuAction] as Swift's synthesized `Codable` does for an enum + * with a single unlabeled associated value: an object keyed by the case name, + * whose value wraps the payload under `_0`. + */ +object MenuActionSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("MenuAction") + + override fun serialize(encoder: Encoder, value: MenuAction) { + val json = encoder as? JsonEncoder + ?: throw SerializationException("MenuAction can only be serialized to JSON") + val element = when (value) { + is MenuAction.Command -> buildJsonObject { + put("command", buildJsonObject { + put("_0", json.json.encodeToJsonElement(MenuActionCommand.serializer(), value.command)) + }) + } + is MenuAction.DeepLink -> buildJsonObject { + put("deepLink", buildJsonObject { put("_0", JsonPrimitive(value.url)) }) + } + } + json.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): MenuAction { + val json = decoder as? JsonDecoder + ?: throw SerializationException("MenuAction can only be deserialized from JSON") + val obj: JsonObject = json.decodeJsonElement().jsonObject + obj["command"]?.let { wrapper -> + val inner = wrapper.jsonObject["_0"] + ?: throw SerializationException("MenuAction.command missing _0") + return MenuAction.Command(json.json.decodeFromJsonElement(MenuActionCommand.serializer(), inner)) + } + obj["deepLink"]?.let { wrapper -> + val url = wrapper.jsonObject["_0"]?.jsonPrimitive?.content + ?: throw SerializationException("MenuAction.deepLink missing _0") + return MenuAction.DeepLink(url) + } + throw SerializationException("Unknown MenuAction case: ${obj.keys}") + } +} + +/** + * A single cross-platform menu entry. Built on the desktop, rendered on Android. + * Optional fields are absent in the JSON when null (Swift omits nil), so the + * defaults below reproduce the desktop's values. + */ +@Serializable +data class MenuItem( + val id: String, + val kind: MenuItemKind = MenuItemKind.ITEM, + val title: String = "", + val systemImage: String? = null, + val role: MenuItemRole? = null, + val isDisabled: Boolean = false, + val action: MenuAction? = null, + val children: List? = null, +) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Models.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Models.kt index 1105787f..759c5191 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Models.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Models.kt @@ -38,7 +38,33 @@ data class SessionSummary( val parentThreadId: String? = null, /** Short label chip (e.g. "Code Review"). Null for ordinary threads. */ val threadLabel: String? = null, -) + /** + * Number of distinct files this thread recorded edits to (the desktop's + * thread file-edit history). Drives whether the "Commit Files" action is + * offered — hidden when 0. Null from older desktops, treated as unknown + * (action still shown). + */ + val changedFileCount: Int? = null, +) { + companion object { + /** Canonical chip label stamped on `[Code Review]` threads. */ + const val CODE_REVIEW_LABEL = "Code Review" + } + + /** + * True when this summary *is* a `[Code Review]` thread. Such threads hide + * the "Code Review" / "Commit Files" actions since you don't review or + * commit a review thread itself. + */ + val isCodeReviewThread: Boolean get() = threadLabel == CODE_REVIEW_LABEL + + /** + * Whether the "Commit Files" action should be offered for this thread. + * Hidden only when the desktop positively reported zero recorded file edits; + * an unknown count (null, older desktop) keeps the action visible. + */ + val hasRecordedFileChanges: Boolean get() = changedFileCount?.let { it > 0 } ?: true +} @Serializable enum class SessionAttentionKind { @@ -148,11 +174,18 @@ data class ProjectBranchInfo( @Serializable(with = UuidSerializer::class) val projectId: UUID, val currentBranch: String, val availableBranches: List? = null, + /** + * Whether the project's working tree has uncommitted changes. Drives whether + * the project-level "Commit All Changes" action is offered — hidden when + * false. Null from older desktops, treated as unknown (action shown). + */ + val hasUncommittedChanges: Boolean? = null, ) /** * Per-repo CI / pull-request status mirrored from the desktop, used to gate the - * "Create Pull Request" menu item (only offered when a branch has no PR yet). + * "Create Pull Request" menu item (only offered when a branch has no PR yet) and + * the "Fix Failing CI" item (only offered when [overallState] is `"failure"`). * Mirrors a subset of Swift `ProjectCIStatus`; unknown keys (workflows, failing, * etc.) are ignored by [RxJson]. */ @@ -162,6 +195,7 @@ data class ProjectCIStatus( val repo: String = "", val branch: String? = null, val found: Boolean = false, + val overallState: String? = null, val prNumber: Int? = null, val prState: String? = null, val prUrl: String? = null, diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt index 60101f76..f540eac2 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt @@ -16,7 +16,13 @@ import app.rxlab.rxcode.proto.AutopilotDocsDocBody import app.rxlab.rxcode.proto.AutopilotDocsListDocumentsBody import app.rxlab.rxcode.proto.AutopilotDocsUploadBody import app.rxlab.rxcode.proto.AutopilotDomain +import app.rxlab.rxcode.proto.AutopilotExecuteCommandBody +import app.rxlab.rxcode.proto.AutopilotExecuteCommandResult import app.rxlab.rxcode.proto.AutopilotIDBody +import app.rxlab.rxcode.proto.AutopilotMenuResult +import app.rxlab.rxcode.proto.AutopilotProjectMenuBody +import app.rxlab.rxcode.proto.MenuActionCommand +import app.rxlab.rxcode.proto.MenuItem import app.rxlab.rxcode.proto.AutopilotOp import app.rxlab.rxcode.proto.AutopilotProjectBody import app.rxlab.rxcode.proto.AutopilotProjectBranchBody @@ -519,6 +525,19 @@ class AutopilotService( ) ).threadId + /** + * Ask the Mac to start a thread that fixes failing CI for the branch (seeded + * with the failing GitHub Actions run(s)). Returns the new thread id so the + * phone can navigate to it once it syncs over. + */ + suspend fun requestProjectFixCI(projectId: UUID, branch: String): String = + decodeResult( + rawCall( + AutopilotDomain.PROJECT, AutopilotOp.PROJECT_FIX_CI, + encodeBody(AutopilotProjectBranchBody(projectId, branch)), + ) + ).threadId + /** Ask the Mac to commit only the files recorded for one thread. */ suspend fun requestThreadCommitFiles(sessionId: String): String = decodeResult( @@ -545,6 +564,42 @@ class AutopilotService( ) ) + // MARK: - Serializable context menus + + /** + * Fetch the project's context menu from the Mac — the same `[MenuItem]` the + * desktop renders, built from its hooks — so Android renders the identical + * items. `branch` scopes a briefing card to its branch. + */ + suspend fun fetchProjectMenu(projectId: UUID, branch: String?): List = + decodeResult( + rawCall( + AutopilotDomain.PROJECT, AutopilotOp.MENU_FOR_PROJECT, + encodeBody(AutopilotProjectMenuBody(projectId, branch)), + ) + ).items + + /** Fetch a thread's context menu from the Mac (see [fetchProjectMenu]). */ + suspend fun fetchThreadMenu(sessionId: String): List = + decodeResult( + rawCall( + AutopilotDomain.PROJECT, AutopilotOp.MENU_FOR_THREAD, + encodeBody(AutopilotThreadBody(sessionId)), + ) + ).items + + /** + * Run a tapped [MenuActionCommand] on the Mac through its shared dispatcher, + * returning a thread to navigate to and/or a URL to open on the phone. + */ + suspend fun executeMenuCommand(command: MenuActionCommand): AutopilotExecuteCommandResult = + decodeResult( + rawCall( + AutopilotDomain.PROJECT, AutopilotOp.MENU_EXECUTE_COMMAND, + encodeBody(AutopilotExecuteCommandBody(command)), + ) + ) + // MARK: - Global search /** One round trip returning thread matches AND published-docs matches. */ diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt index 38c01718..58c63778 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt @@ -6,9 +6,12 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.rxlab.rxcode.BuildConfig +import app.rxlab.rxcode.proto.AutopilotExecuteCommandResult import app.rxlab.rxcode.proto.AutopilotProjectSecretsDownloadResult import app.rxlab.rxcode.proto.AutopilotProjectSecretsWriteBody import app.rxlab.rxcode.proto.AutopilotProjectStatus +import app.rxlab.rxcode.proto.MenuActionCommand +import app.rxlab.rxcode.proto.MenuItem import app.rxlab.rxcode.proto.BranchOpRequestPayload import app.rxlab.rxcode.proto.CancelStreamPayload import app.rxlab.rxcode.proto.CreateProjectRequestPayload @@ -860,6 +863,20 @@ class MobileAppState @Inject constructor( suspend fun requestThreadCommitFiles(sessionId: String): String = autopilot.requestThreadCommitFiles(sessionId) + // MARK: - Serializable context menus (desktop is the single source of truth) + + /** Fetch the desktop-built project context menu (optionally branch-scoped). */ + suspend fun fetchProjectMenu(projectId: UUID, branch: String?): List = + autopilot.fetchProjectMenu(projectId, branch) + + /** Fetch the desktop-built context menu for one thread. */ + suspend fun fetchThreadMenu(sessionId: String): List = + autopilot.fetchThreadMenu(sessionId) + + /** Run a tapped menu command on the Mac; returns a thread and/or URL to open. */ + suspend fun executeMenuCommand(command: MenuActionCommand): AutopilotExecuteCommandResult = + autopilot.executeMenuCommand(command) + /** * Download a secret environment into the project folder: decrypt on-device * with the passkey-derived KEK, then relay the plaintext for the Mac to write. diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/MobileMenuSupport.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/MobileMenuSupport.kt new file mode 100644 index 00000000..19bd745b --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/MobileMenuSupport.kt @@ -0,0 +1,147 @@ +package app.rxlab.rxcode.ui.autopilot + +import android.net.Uri +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.MergeType +import androidx.compose.material.icons.outlined.Book +import androidx.compose.material.icons.outlined.Build +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.RateReview +import androidx.compose.material.icons.outlined.Sell +import androidx.compose.material.icons.outlined.StopCircle +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import app.rxlab.rxcode.proto.MenuAction +import app.rxlab.rxcode.proto.MenuCommandKind +import app.rxlab.rxcode.proto.MenuItem +import app.rxlab.rxcode.proto.MenuItemKind +import app.rxlab.rxcode.proto.MenuItemRole + +/** + * Shared rendering helpers for the desktop's serialized context menu on Android. + * The menu items themselves are the single source of truth (built by desktop + * hooks); these only translate the cross-platform identifiers into Android + * presentation — the SF Symbol → Material icon mapping, the deep-link → on-device + * target mapping (mirroring iOS `mobileMenuDeepLinkTarget`), and the loading + * dialog copy for an async command. + */ + +/** The on-device UI a menu deep link should open, or [None] when it has no Android equivalent. */ +sealed interface MobileMenuDeepLinkTarget { + data object SecretsDownload : MobileMenuDeepLinkTarget + data object ReleaseCreate : MobileMenuDeepLinkTarget + /** Open a new on-device setup chat prefilled with [prompt]. */ + data class SetupChat(val prompt: String) : MobileMenuDeepLinkTarget + data object None : MobileMenuDeepLinkTarget +} + +private const val SECRETS_SETUP_PROMPT = + "Set up encrypted secrets for this repository so I can sync environment files securely." +private const val DOCS_SETUP_PROMPT = + "Set up documentation publishing for this repository so its docs are indexed into RxCode docs search." +private const val RELEASE_SETUP_PROMPT = + "Set up a release workflow for this repository (semantic-release + a GitHub Actions release workflow)." +private const val CI_SETUP_PROMPT = + "Set up CI auto-update for this repository so failing CI is fixed automatically." + +/** + * Map a `rxcode://menu/?…` deep link to the matching on-device target. + * Mirrors iOS `mobileMenuDeepLinkTarget`; docs-search and unknown actions have no + * on-device sheet and resolve to [MobileMenuDeepLinkTarget.None]. + */ +fun mobileMenuDeepLinkTarget(url: String): MobileMenuDeepLinkTarget { + val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return MobileMenuDeepLinkTarget.None + if (uri.scheme != "rxcode" || uri.host != "menu") return MobileMenuDeepLinkTarget.None + val action = uri.path?.trimStart('/').orEmpty() + return when (action) { + "secrets-download" -> MobileMenuDeepLinkTarget.SecretsDownload + "release-create" -> MobileMenuDeepLinkTarget.ReleaseCreate + "secrets-setup" -> MobileMenuDeepLinkTarget.SetupChat(SECRETS_SETUP_PROMPT) + "docs-setup" -> MobileMenuDeepLinkTarget.SetupChat(DOCS_SETUP_PROMPT) + "release-setup" -> MobileMenuDeepLinkTarget.SetupChat(RELEASE_SETUP_PROMPT) + "ci-setup" -> MobileMenuDeepLinkTarget.SetupChat(CI_SETUP_PROMPT) + else -> MobileMenuDeepLinkTarget.None + } +} + +/** Material icon for a desktop SF Symbol name, or null to render no leading icon. */ +fun menuItemIcon(systemImage: String?): ImageVector? = when (systemImage) { + "checklist" -> Icons.Outlined.RateReview + "checkmark.circle" -> Icons.Outlined.CheckCircle + "arrow.triangle.pull" -> Icons.AutoMirrored.Outlined.MergeType + "wrench.and.screwdriver" -> Icons.Outlined.Build + "stop.circle" -> Icons.Outlined.StopCircle + "key.fill" -> Icons.Outlined.Key + "tag.fill" -> Icons.Outlined.Sell + "books.vertical.fill" -> Icons.Outlined.Book + "arrow.triangle.2.circlepath" -> Icons.Outlined.Sync + else -> null +} + +/** + * Loading dialog copy (title to message) shown while an `isAsync` command runs. + * Only Create PR is async today; the generic fallback covers any future ones. + */ +fun commandLoadingText(kind: MenuCommandKind): Pair = when (kind) { + MenuCommandKind.PROJECT_CREATE_PULL_REQUEST -> + "Creating Pull Request…" to "The Mac is pushing the branch and opening the PR." + else -> + "Working…" to "The Mac is performing the requested action." +} + +/** + * Renders the desktop's serialized [MenuItem]s as dropdown rows: separators as + * dividers, leaf items as tappable rows (with the desktop's destructive role and + * disabled state honored), and — defensively — submenu children flattened inline + * (the project/thread menus the desktop ships are flat today). Shared by the + * project menu and the per-thread menus so every surface renders identically. + */ +@Composable +fun ColumnScope.SerializedMenuItems( + items: List, + onAction: (MenuAction?) -> Unit, +) { + items.forEach { item -> + when (item.kind) { + MenuItemKind.SEPARATOR -> HorizontalDivider() + MenuItemKind.ITEM -> { + val children = item.children + if (!children.isNullOrEmpty()) { + SerializedMenuItems(children, onAction) + } else { + val icon = menuItemIcon(item.systemImage) + val isDestructive = item.role == MenuItemRole.DESTRUCTIVE + DropdownMenuItem( + text = { + Text( + item.title, + color = if (isDestructive) MaterialTheme.colorScheme.error else Color.Unspecified, + ) + }, + leadingIcon = icon?.let { + { + Icon( + it, + contentDescription = null, + tint = if (isDestructive) MaterialTheme.colorScheme.error else LocalContentColor.current, + ) + } + }, + enabled = !item.isDisabled, + onClick = { onAction(item.action) }, + ) + } + } + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt index 038f0346..141c2a2f 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt @@ -4,16 +4,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.MergeType import androidx.compose.material.icons.automirrored.outlined.OpenInNew -import androidx.compose.material.icons.outlined.Book -import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.HourglassEmpty -import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material.icons.outlined.RateReview -import androidx.compose.material.icons.outlined.Sell import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -25,6 +19,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,7 +29,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp -import app.rxlab.rxcode.proto.AutopilotProjectStatus +import app.rxlab.rxcode.proto.MenuAction +import app.rxlab.rxcode.proto.MenuActionCommand +import app.rxlab.rxcode.proto.MenuCommandKind +import app.rxlab.rxcode.proto.MenuItem import app.rxlab.rxcode.proto.Project import app.rxlab.rxcode.proto.ProjectBranchInfo import app.rxlab.rxcode.state.MobileAppState @@ -42,32 +40,26 @@ import app.rxlab.rxcode.ui.sheets.ProjectReleaseCreateSheet import app.rxlab.rxcode.ui.sheets.ProjectSecretsDownloadSheet import kotlinx.coroutines.launch -private const val SECRETS_SETUP_PROMPT = - "Set up encrypted secrets for this repository so I can sync environment files securely." -private const val DOCS_SETUP_PROMPT = - "Set up documentation publishing for this repository so its docs are indexed into RxCode docs search." -private const val RELEASE_SETUP_PROMPT = - "Set up a release workflow for this repository (semantic-release + a GitHub Actions release workflow)." - /** - * One-to-one mobile mirror of the desktop project/briefing autopilot context - * menu (`ProjectAutopilotMenuItems` on iOS), plus the per-screen extras: a - * "Create Pull Request" / "Open on GitHub" pair (briefing detail) and an - * optional destructive "Delete Project" (session list). + * The project / briefing actions menu — now a thin renderer of the **desktop's** + * serialized context menu. The Mac builds the same `[MenuItem]` its own menus + * render (from its hooks: Code Review, Commit, Create PR, Fix CI, plus the + * autopilot secrets/docs/release/CI setup items) and ships it over the relay; + * Android decodes and renders the identical items, exactly like iOS — so the + * menu's contents, gating, and per-item behavior have one source of truth. * - * Drop into a `TopAppBar`'s `actions = { }`. Autopilot items only appear for - * repo-backed projects, gated on the per-project [AutopilotProjectStatus] this - * loads on first composition. Every item is desktop-mediated except "Download - * Secret", which decrypts on-device and relays plaintext for the Mac to write. + * `.command` items run on the Mac via [MobileAppState.executeMenuCommand] (an + * `isAsync` command — e.g. Create PR — blocks behind a loading dialog and waits); + * `.deepLink` items open the matching on-device sheet / setup chat. "Open on + * GitHub" and the optional destructive "Delete Project" are Android-local extras + * the desktop menu doesn't carry. * - * @param branch current branch — enables "Create Pull Request" (hidden when null - * or `"unknown"`) and seeds the release form's branch picker. - * @param prNumber the open PR number for [branch], or null when none exists. - * Mirrors the desktop briefing gate: "Create Pull Request" is only offered - * when the branch has no PR linked yet. - * @param onOpenSession navigate to a chat after a setup action creates one. - * @param onProjectDeleted called after confirming "Delete Project" so the host - * can pop back (only reachable when [includeDeleteProject] is true). + * @param branch current branch — scopes the fetched menu (briefing cards target + * their own branch); `"unknown"` is treated as no branch. + * @param prNumber the open PR number for [branch], if any — only used to refetch + * the menu when PR state changes (the desktop decides whether "Create PR" shows). + * @param onOpenSession navigate to a chat after an action spawns / targets one. + * @param onProjectDeleted called after confirming "Delete Project". */ @Composable fun ProjectActionsMenu( @@ -83,27 +75,23 @@ fun ProjectActionsMenu( val scope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current val hasRepo = !project.gitHubRepo.isNullOrEmpty() - // Offer "Create PR" for a real branch with no open PR yet (mirrors the - // desktop briefing PR button); once a PR exists "Open on GitHub" covers it. - val canCreatePR = branch != null && !branch.equals("unknown", ignoreCase = true) && prNumber == null - - // Offer a branch-wide code review for any real branch (mirrors the desktop - // briefing/project "Code Review" menu). - val canCodeReview = branch != null && !branch.equals("unknown", ignoreCase = true) + val scopedBranch = branch?.takeIf { it.isNotEmpty() && !it.equals("unknown", ignoreCase = true) } var menuOpen by remember { mutableStateOf(false) } - var status by remember(project.id) { mutableStateOf(null) } + var menuItems by remember(project.id) { mutableStateOf?>(null) } var showDownload by remember { mutableStateOf(false) } var showReleaseCreate by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) } - var isCreatingPR by remember { mutableStateOf(false) } - var isCreatingReview by remember { mutableStateOf(false) } - var isCommittingAll by remember { mutableStateOf(false) } + // Set to a command's kind while an async command runs, driving the loading + // dialog; a non-async command never sets it. `dispatching` guards re-entrancy. + var dispatching by remember { mutableStateOf(false) } + var asyncCommand by remember { mutableStateOf(null) } var info by remember { mutableStateOf(null) } - // Only repo-backed projects have autopilot state to load. - androidx.compose.runtime.LaunchedEffect(project.id) { - if (hasRepo) status = runCatching { viewModel.projectAutopilotStatus(project.id) }.getOrNull() + // Prefetch the desktop menu, re-keyed on the git-dirty + PR state it's gated + // by so it refreshes after commits / PR creation land in the snapshot. + LaunchedEffect(project.id, scopedBranch, branchInfo?.hasUncommittedChanges, prNumber) { + menuItems = runCatching { viewModel.fetchProjectMenu(project.id, scopedBranch) }.getOrNull() } fun startSetupChat(prompt: String) { @@ -112,54 +100,41 @@ fun ProjectActionsMenu( onOpenSession(sessionId) } - fun createPullRequest() { - if (isCreatingPR || branch == null) return - isCreatingPR = true + fun runCommand(command: MenuActionCommand) { + if (dispatching) return menuOpen = false + dispatching = true + if (command.isAsync) asyncCommand = command.kind scope.launch { try { - val url = viewModel.requestProjectCreatePullRequest(project.id, branch) - viewModel.requestSnapshot("pr_created") - uriHandler.openUri(url) + val result = viewModel.executeMenuCommand(command) + viewModel.requestSnapshot("menu_command") + result.threadId?.let { onOpenSession(it) } + result.openURL?.let { uriHandler.openUri(it) } } catch (t: Throwable) { - info = t.message ?: "Couldn't create the pull request." + info = t.message ?: "Couldn't complete the action." } finally { - isCreatingPR = false + dispatching = false + asyncCommand = null } } } - fun createCodeReview() { - if (isCreatingReview || branch == null) return - isCreatingReview = true + fun handleDeepLink(url: String) { menuOpen = false - scope.launch { - try { - val threadId = viewModel.requestProjectCreateCodeReview(project.id, branch) - viewModel.requestSnapshot("code_review_started") - onOpenSession(threadId) - } catch (t: Throwable) { - info = t.message ?: "Couldn't start the code review." - } finally { - isCreatingReview = false - } + when (val target = mobileMenuDeepLinkTarget(url)) { + is MobileMenuDeepLinkTarget.SecretsDownload -> showDownload = true + is MobileMenuDeepLinkTarget.ReleaseCreate -> showReleaseCreate = true + is MobileMenuDeepLinkTarget.SetupChat -> startSetupChat(target.prompt) + is MobileMenuDeepLinkTarget.None -> Unit } } - fun commitAllChanges() { - if (isCommittingAll) return - isCommittingAll = true - menuOpen = false - scope.launch { - try { - val threadId = viewModel.requestProjectCommitAll(project.id) - viewModel.requestSnapshot("commit_started") - onOpenSession(threadId) - } catch (t: Throwable) { - info = t.message ?: "Couldn't start the commit." - } finally { - isCommittingAll = false - } + fun handleAction(action: MenuAction?) { + when (action) { + is MenuAction.Command -> runCommand(action.command) + is MenuAction.DeepLink -> handleDeepLink(action.url) + null -> Unit } } @@ -168,90 +143,33 @@ fun ProjectActionsMenu( } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - DropdownMenuItem( - text = { Text("Commit All Changes") }, - leadingIcon = { Icon(Icons.Outlined.CheckCircle, contentDescription = null) }, - enabled = !isCommittingAll, - onClick = { commitAllChanges() }, - ) + val items = menuItems + if (items == null) { + DropdownMenuItem( + text = { Text("Loading…") }, + leadingIcon = { Icon(Icons.Outlined.HourglassEmpty, contentDescription = null) }, + enabled = false, + onClick = {}, + ) + } else { + SerializedMenuItems(items, onAction = ::handleAction) + } + // Android-local extras the desktop menu doesn't carry. if (hasRepo) { - HorizontalDivider() - val loaded = status - if (loaded == null) { - DropdownMenuItem( - text = { Text("Loading Autopilot…") }, - leadingIcon = { Icon(Icons.Outlined.HourglassEmpty, contentDescription = null) }, - enabled = false, - onClick = {}, - ) - } else { - // Secrets: download when present, otherwise offer setup. - if (loaded.hasSecrets) { - DropdownMenuItem( - text = { Text("Download Secret") }, - leadingIcon = { Icon(Icons.Outlined.Key, contentDescription = null) }, - onClick = { menuOpen = false; showDownload = true }, - ) - } else { - DropdownMenuItem( - text = { Text("Set Up Secrets") }, - leadingIcon = { Icon(Icons.Outlined.Key, contentDescription = null) }, - onClick = { startSetupChat(SECRETS_SETUP_PROMPT) }, - ) - } - // Docs: only offer setup when not yet indexed. - if (!loaded.hasDocs) { - DropdownMenuItem( - text = { Text("Set Up Docs") }, - leadingIcon = { Icon(Icons.Outlined.Book, contentDescription = null) }, - onClick = { startSetupChat(DOCS_SETUP_PROMPT) }, - ) - } - // Release: create when configured, otherwise offer setup. - if (loaded.hasRelease) { - DropdownMenuItem( - text = { Text("Create Release") }, - leadingIcon = { Icon(Icons.Outlined.Sell, contentDescription = null) }, - onClick = { menuOpen = false; showReleaseCreate = true }, - ) - } else { - DropdownMenuItem( - text = { Text("Set Up Release Workflow") }, - leadingIcon = { Icon(Icons.Outlined.Sell, contentDescription = null) }, - onClick = { startSetupChat(RELEASE_SETUP_PROMPT) }, - ) - } - if (canCreatePR) { - DropdownMenuItem( - text = { Text("Create Pull Request") }, - leadingIcon = { Icon(Icons.AutoMirrored.Outlined.MergeType, contentDescription = null) }, - enabled = !isCreatingPR, - onClick = { createPullRequest() }, - ) - } - if (canCodeReview) { - DropdownMenuItem( - text = { Text("Code Review for $branch") }, - leadingIcon = { Icon(Icons.Outlined.RateReview, contentDescription = null) }, - enabled = !isCreatingReview, - onClick = { createCodeReview() }, - ) - } - HorizontalDivider() - DropdownMenuItem( - text = { Text("Open on GitHub") }, - leadingIcon = { Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = null) }, - onClick = { - menuOpen = false - uriHandler.openUri("https://github.com/${project.gitHubRepo}") - }, - ) - } + if (!items.isNullOrEmpty()) HorizontalDivider() + DropdownMenuItem( + text = { Text("Open on GitHub") }, + leadingIcon = { Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = null) }, + onClick = { + menuOpen = false + uriHandler.openUri("https://github.com/${project.gitHubRepo}") + }, + ) } if (includeDeleteProject) { - if (hasRepo) HorizontalDivider() + if (hasRepo || !items.isNullOrEmpty()) HorizontalDivider() DropdownMenuItem( text = { Text("Delete Project", color = MaterialTheme.colorScheme.error) }, leadingIcon = { @@ -299,43 +217,16 @@ fun ProjectActionsMenu( ) } - if (isCreatingPR) { - AlertDialog( - onDismissRequest = {}, - confirmButton = {}, - title = { Text("Creating Pull Request…") }, - text = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) - Text("The Mac is pushing the branch and opening the PR.") - } - }, - ) - } - - if (isCreatingReview) { - AlertDialog( - onDismissRequest = {}, - confirmButton = {}, - title = { Text("Starting Code Review…") }, - text = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) - Text("The Mac is starting a Code Review thread for this branch.") - } - }, - ) - } - - if (isCommittingAll) { + asyncCommand?.let { kind -> + val (title, message) = commandLoadingText(kind) AlertDialog( onDismissRequest = {}, confirmButton = {}, - title = { Text("Committing Changes…") }, + title = { Text(title) }, text = { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) - Text("The Mac is starting a commit thread for this project.") + Text(message) } }, ) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt index 34e40292..f19d594d 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.Send import androidx.compose.material.icons.automirrored.outlined.ViewSidebar import androidx.compose.material.icons.outlined.Build -import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Difference import androidx.compose.material.icons.outlined.Edit @@ -46,7 +45,6 @@ import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.PlayArrow -import androidx.compose.material.icons.outlined.RateReview import androidx.compose.material.icons.outlined.QuestionAnswer import androidx.compose.material.icons.outlined.QueuePlayNext import androidx.compose.material3.AssistChip @@ -83,6 +81,8 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import app.rxlab.rxcode.proto.ChatMessage +import app.rxlab.rxcode.proto.MenuAction +import app.rxlab.rxcode.proto.MenuItem import app.rxlab.rxcode.proto.MessageBlock import app.rxlab.rxcode.proto.PendingQuestionPayload import app.rxlab.rxcode.proto.Role @@ -93,6 +93,7 @@ import app.rxlab.rxcode.state.isDraftSessionId import app.rxlab.rxcode.state.resolveSessionId import app.rxlab.rxcode.state.runProfilesFor import app.rxlab.rxcode.state.runTasksFor +import app.rxlab.rxcode.ui.autopilot.SerializedMenuItems import app.rxlab.rxcode.ui.browser.BrowserUrlDetector import app.rxlab.rxcode.ui.browser.InAppBrowserScreen import app.rxlab.rxcode.proto.RunProfile @@ -144,6 +145,25 @@ fun ChatScreen( var openEditPreview by remember { mutableStateOf(null) } var menuExpanded by remember { mutableStateOf(false) } val menuScope = rememberCoroutineScope() + // Desktop-mediated thread actions (Code Review / Commit Files / Stop) come from + // the Mac as its serialized thread menu — fetched lazily, re-keyed on the + // gating state — so they match the desktop's items exactly. + var threadMenu by remember(resolvedId) { mutableStateOf?>(null) } + LaunchedEffect(resolvedId, session?.hasRecordedFileChanges, session?.isStreaming) { + threadMenu = if (isDraftSessionId(resolvedId)) null + else runCatching { viewModel.fetchThreadMenu(resolvedId) }.getOrNull() + } + fun runThreadAction(action: MenuAction?) { + val command = (action as? MenuAction.Command)?.command ?: return + menuExpanded = false + menuScope.launch { + runCatching { + val result = viewModel.executeMenuCommand(command) + viewModel.requestSnapshot("menu_command") + result.threadId?.let { viewModel.selectSession(it) } + } + } + } var showRunProfiles by remember { mutableStateOf(false) } var editingProfile by remember { mutableStateOf(null) } var showBrowser by remember { mutableStateOf(false) } @@ -283,34 +303,12 @@ fun ChatScreen( }, leadingIcon = { Icon(Icons.Outlined.Difference, contentDescription = null) }, ) - androidx.compose.material3.DropdownMenuItem( - text = { Text("Code Review") }, - onClick = { - menuExpanded = false - menuScope.launch { - runCatching { - val threadId = viewModel.requestThreadCreateCodeReview(resolvedId) - viewModel.requestSnapshot("code_review_started") - viewModel.selectSession(threadId) - } - } - }, - leadingIcon = { Icon(Icons.Outlined.RateReview, contentDescription = null) }, - ) - androidx.compose.material3.DropdownMenuItem( - text = { Text("Commit Files") }, - onClick = { - menuExpanded = false - menuScope.launch { - runCatching { - val threadId = viewModel.requestThreadCommitFiles(resolvedId) - viewModel.requestSnapshot("commit_started") - viewModel.selectSession(threadId) - } - } - }, - leadingIcon = { Icon(Icons.Outlined.CheckCircle, contentDescription = null) }, - ) + // Desktop-built thread actions (Code Review / Commit Files / + // Stop Code Review), gated by the Mac — the single source of + // truth, matching iOS. + threadMenu?.takeIf { it.isNotEmpty() }?.let { items -> + SerializedMenuItems(items, onAction = ::runThreadAction) + } androidx.compose.material3.DropdownMenuItem( text = { Text("Open in Browser") }, onClick = { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt index 09fbc0f7..5719d5a7 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.material.icons.outlined.DriveFileRenameOutline import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.RadioButtonUnchecked -import androidx.compose.material.icons.outlined.RateReview import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.CardDefaults @@ -41,6 +40,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -49,6 +49,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -61,12 +62,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.MenuAction +import app.rxlab.rxcode.proto.MenuItem import app.rxlab.rxcode.proto.SessionAttentionKind import app.rxlab.rxcode.proto.SessionSummary import app.rxlab.rxcode.proto.TodoItem import app.rxlab.rxcode.state.MobileAppState import app.rxlab.rxcode.state.MobileState import app.rxlab.rxcode.ui.autopilot.ProjectActionsMenu +import app.rxlab.rxcode.ui.autopilot.SerializedMenuItems import app.rxlab.rxcode.ui.sheets.NewThreadSheet import app.rxlab.rxcode.ui.sheets.RenameThreadSheet import app.rxlab.rxcode.ui.util.HapticEvent @@ -121,28 +125,6 @@ fun SessionsScreen( val scope = rememberCoroutineScope() var refreshing by remember { mutableStateOf(false) } - // Start a `[Code Review]` thread on the Mac for one thread's changes (the - // manual equivalent of the built-in Code Review hook), then navigate to it. - fun startThreadReview(sessionId: String) { - scope.launch { - runCatching { - val threadId = viewModel.requestThreadCreateCodeReview(sessionId) - viewModel.requestSnapshot("code_review_started") - onNewThread(threadId) - } - } - } - - fun startThreadCommit(sessionId: String) { - scope.launch { - runCatching { - val threadId = viewModel.requestThreadCommitFiles(sessionId) - viewModel.requestSnapshot("commit_started") - onNewThread(threadId) - } - } - } - Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { @@ -228,8 +210,8 @@ fun SessionsScreen( viewModel.archiveThread(parent.id) }, onDelete = { deleteTarget = parent }, - onCodeReview = { startThreadReview(parent.id) }, - onCommitFiles = { startThreadCommit(parent.id) }, + viewModel = viewModel, + onOpenThread = onNewThread, reviewCount = children.size, isExpanded = expanded, isReviewing = children.any { it.isStreaming }, @@ -261,8 +243,8 @@ fun SessionsScreen( viewModel.archiveThread(child.id) }, onDelete = { deleteTarget = child }, - onCodeReview = { startThreadReview(child.id) }, - onCommitFiles = { startThreadCommit(child.id) }, + viewModel = viewModel, + onOpenThread = onNewThread, indentLevel = 1, titleOverride = "Review ${index + 1}", showLabel = false, @@ -371,8 +353,8 @@ private fun SessionCard( onRename: () -> Unit, onArchive: () -> Unit, onDelete: () -> Unit, - onCodeReview: () -> Unit = {}, - onCommitFiles: () -> Unit = {}, + viewModel: MobileAppState, + onOpenThread: (String) -> Unit = {}, reviewCount: Int = 0, isExpanded: Boolean = false, isReviewing: Boolean = false, @@ -382,6 +364,27 @@ private fun SessionCard( showLabel: Boolean = true, ) { var menuOpen by remember { mutableStateOf(false) } + val cardScope = rememberCoroutineScope() + // The desktop-mediated thread actions (Code Review / Commit Files / Stop) come + // from the Mac as the same serialized menu it renders — fetched lazily and + // re-keyed on the gating state so it refreshes after edits / a review starts. + // Rename / Archive / Delete below are Android-local. + var threadMenu by remember(session.id) { mutableStateOf?>(null) } + LaunchedEffect(session.id, session.hasRecordedFileChanges, isReviewing) { + threadMenu = runCatching { viewModel.fetchThreadMenu(session.id) }.getOrNull() + } + + fun runThreadAction(action: MenuAction?) { + val command = (action as? MenuAction.Command)?.command ?: return + menuOpen = false + cardScope.launch { + runCatching { + val result = viewModel.executeMenuCommand(command) + viewModel.requestSnapshot("menu_command") + result.threadId?.let { onOpenThread(it) } + } + } + } ElevatedCard( modifier = Modifier .fillMaxWidth() @@ -469,16 +472,13 @@ private fun SessionCard( Icon(Icons.Outlined.MoreVert, contentDescription = "More") } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - DropdownMenuItem( - text = { Text("Code Review") }, - leadingIcon = { Icon(Icons.Outlined.RateReview, contentDescription = null) }, - onClick = { menuOpen = false; onCodeReview() }, - ) - DropdownMenuItem( - text = { Text("Commit Files") }, - leadingIcon = { Icon(Icons.Outlined.CheckCircle, contentDescription = null) }, - onClick = { menuOpen = false; onCommitFiles() }, - ) + // Desktop-built thread actions (Code Review / Commit Files / Stop + // Code Review), gated by the Mac. Empty for a `[Code Review]` + // thread, which the desktop excludes. + threadMenu?.takeIf { it.isNotEmpty() }?.let { items -> + SerializedMenuItems(items, onAction = ::runThreadAction) + HorizontalDivider() + } DropdownMenuItem( text = { Text("Rename") }, leadingIcon = { Icon(Icons.Outlined.DriveFileRenameOutline, contentDescription = null) }, diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index a3e5aa45..16e09ffe 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -1838,6 +1838,15 @@ } } } + }, + "Commit All Changes" : { + + }, + "Commit Files" : { + + }, + "Committing Changes…" : { + }, "Computer Status" : { "localizations" : { @@ -9126,6 +9135,9 @@ } } } + }, + "The Mac is performing the requested action." : { + }, "The Mac is pushing the branch and opening the PR." : { "localizations" : { @@ -9145,6 +9157,9 @@ }, "The Mac is starting a Code Review thread for this branch." : { + }, + "The Mac is starting a commit thread for this project." : { + }, "The Mac returned an empty response." : { "localizations" : { diff --git a/RxCodeMobile/State/MobileAppState+Autopilot.swift b/RxCodeMobile/State/MobileAppState+Autopilot.swift index cea75b8d..fea52eb3 100644 --- a/RxCodeMobile/State/MobileAppState+Autopilot.swift +++ b/RxCodeMobile/State/MobileAppState+Autopilot.swift @@ -502,4 +502,31 @@ extension MobileAppState { body: AutopilotProjectSecretsWriteBody(projectId: projectId, files: files, overwrite: overwrite), as: AutopilotProjectSecretsDownloadResult.self) } + + // MARK: - Serializable context menus + + /// Fetches the project's context menu from the Mac — the same `[MenuItem]` + /// the desktop renders, built from its hooks — so the phone renders the + /// identical items. + func fetchProjectMenu(projectId: UUID, branch: String? = nil) async throws -> [MenuItem] { + try await autopilotSend(.project, .menuForProject, + body: AutopilotProjectMenuBody(projectId: projectId, branch: branch), + as: AutopilotMenuResult.self).items + } + + /// Fetches a thread's context menu from the Mac (see `fetchProjectMenu`). + func fetchThreadMenu(sessionId: String) async throws -> [MenuItem] { + try await autopilotSend(.project, .menuForThread, + body: AutopilotThreadBody(sessionId: sessionId), + as: AutopilotMenuResult.self).items + } + + /// Runs a tapped `MenuActionCommand` on the Mac through its shared menu + /// dispatcher, returning a thread to navigate to and/or a URL to open. + @discardableResult + func executeMenuCommand(_ command: MenuActionCommand) async throws -> AutopilotExecuteCommandResult { + try await autopilotSend(.project, .menuExecuteCommand, + body: AutopilotExecuteCommandBody(command: command), + as: AutopilotExecuteCommandResult.self) + } } diff --git a/RxCodeMobile/State/MobileAppState+Inbound.swift b/RxCodeMobile/State/MobileAppState+Inbound.swift index 2ace451f..828eed0d 100644 --- a/RxCodeMobile/State/MobileAppState+Inbound.swift +++ b/RxCodeMobile/State/MobileAppState+Inbound.swift @@ -111,6 +111,12 @@ extension MobileAppState { return (info.projectId, list) } ) + projectDirtyByProject = Dictionary( + uniqueKeysWithValues: branches.compactMap { info -> (UUID, Bool)? in + guard let dirty = info.hasUncommittedChanges else { return nil } + return (info.projectId, dirty) + } + ) } if let active = snap.activeSessionID { if let messages = snap.activeSessionMessages { diff --git a/RxCodeMobile/State/MobileAppState+Sync.swift b/RxCodeMobile/State/MobileAppState+Sync.swift index d0d9cb86..1df5c8b2 100644 --- a/RxCodeMobile/State/MobileAppState+Sync.swift +++ b/RxCodeMobile/State/MobileAppState+Sync.swift @@ -466,6 +466,7 @@ extension MobileAppState { desktopWebProxy = nil projectBranches = [:] availableBranchesByProject = [:] + projectDirtyByProject = [:] runProfilesByProject = [:] runTasks = [] inFlightRunProfileRequests = [] diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index d6397bc2..22a02422 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -107,6 +107,11 @@ final class MobileAppState: ObservableObject { @Published var projectBranches: [UUID: String] = [:] /// Local branch list per project, mirrored from the desktop's snapshot. @Published var availableBranchesByProject: [UUID: [String]] = [:] + /// Per-project working-tree dirty flag, mirrored from the desktop's snapshot. + /// Gates the project-level "Commit All Changes" action — hidden when a + /// project is positively known to be clean. A missing entry (older desktop or + /// not-yet-synced) is treated as "unknown" and keeps the action visible. + @Published var projectDirtyByProject: [UUID: Bool] = [:] /// Desktop-owned run profiles per project, mirrored into the mobile app. @Published var runProfilesByProject: [UUID: [RunProfile]] = [:] /// Recent and active run tasks mirrored from the desktop. diff --git a/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift index 27207408..8857f6cb 100644 --- a/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift +++ b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift @@ -60,13 +60,24 @@ struct ProjectAutopilotMenuItems: View { var isCommittingAll: Bool = false var onCommitAll: () -> Void = {} + /// Hide "Commit All Changes" when the project is positively known to have a + /// clean working tree (synced from the desktop). Unknown → still shown. + private var showCommitAll: Bool { + state.projectDirtyByProject[project.id] != false + } + var body: some View { - commitAllItem() + if showCommitAll { + commitAllItem() + } // Mirror the desktop guard: no repo → no autopilot items. if project.gitHubRepo == nil { EmptyView() } else if let status { - Divider() + // Only separate from the commit item when it's actually shown. + if showCommitAll { + Divider() + } secretsItem(status) docsItem(status) releaseItem(status) diff --git a/RxCodeMobile/Views/Menu/ProjectRemoteMenu.swift b/RxCodeMobile/Views/Menu/ProjectRemoteMenu.swift new file mode 100644 index 00000000..d4fc8e17 --- /dev/null +++ b/RxCodeMobile/Views/Menu/ProjectRemoteMenu.swift @@ -0,0 +1,167 @@ +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// The on-device autopilot UI a menu deep link should open. Shared by every +/// mobile surface that renders the desktop's serialized menu so the secrets / +/// docs / release / CI setup prompts live in one place. +enum MobileMenuDeepLinkTarget { + case secretsDownload + case releaseCreate + /// Open a new on-device setup chat prefilled with this prompt. + case setupChat(String) +} + +/// Map a `rxcode://menu/…` deep link to the matching on-device target, or `nil` +/// when there's no on-device equivalent (e.g. docs search is desktop-only). +func mobileMenuDeepLinkTarget(_ url: URL) -> MobileMenuDeepLinkTarget? { + guard let parsed = MenuDeepLink.parse(url) else { return nil } + switch parsed.action { + case MenuDeepLink.secretsDownload: + return .secretsDownload + case MenuDeepLink.secretsSetup: + return .setupChat("Set up encrypted secrets for this repository so I can sync environment files securely.") + case MenuDeepLink.docsSetup: + return .setupChat("Set up documentation publishing for this repository so its docs are indexed into RxCode docs search.") + case MenuDeepLink.releaseCreate: + return .releaseCreate + case MenuDeepLink.releaseSetup: + return .setupChat("Set up a release workflow for this repository (semantic-release + a GitHub Actions release workflow).") + case MenuDeepLink.ciSetup: + return .setupChat("Set up CI auto-update for this repository so failing CI is fixed automatically.") + default: + return nil // docs-search and unknown actions have no on-device sheet. + } +} + +/// Adds a project context menu backed by the desktop's serializable menu: the +/// same hook-built `[MenuItem]` the Mac renders, fetched over the relay and +/// rendered on-device. Commands run on the Mac via the shared dispatcher; deep +/// links open the matching on-device autopilot sheet / setup chat. +/// +/// Because `.contextMenu` builds synchronously, the menu is prefetched into state +/// when the host appears. Reused by the project sidebar cards and the briefing +/// cards so both surfaces share one implementation. +extension View { + /// Attach the desktop-backed project context menu. A no-op when `project` is + /// nil (e.g. a briefing card whose project isn't resolved yet). + /// + /// Pass `branch` on a briefing card (which represents one branch): the menu + /// then leads with branch-scoped Code Review / Commit All commands (carrying + /// that branch) ahead of the project's autopilot items, mirroring the desktop + /// briefing menu. With `branch == nil` (project list), the generic project + /// menu is used as-is. + @ViewBuilder + func projectRemoteContextMenu(project: Project?, branch: String? = nil) -> some View { + if let project { + modifier(ProjectRemoteMenuModifier(project: project, branch: branch)) + } else { + self + } + } +} + +private struct ProjectRemoteMenuModifier: ViewModifier { + @EnvironmentObject private var state: MobileAppState + @Environment(\.openURL) private var openURL + let project: Project + let branch: String? + + @State private var items: [MenuItem] = [] + @State private var showingSecretsDownload = false + @State private var showingReleaseCreate = false + @State private var setupChat: AutopilotSetupChat? + @State private var info: AutopilotMenuInfo? + @State private var autopilotStatus: AutopilotProjectStatus? + @State private var isRunning = false + + /// Re-key the prefetch on the branch and the desktop status the menu is gated + /// by (git dirty + PR state), so the menu refreshes after commits / PR + /// creation / status polls land in the snapshot instead of showing stale + /// actions. + private var refreshKey: String { + let dirty = state.projectDirtyByProject[project.id].map(String.init) ?? "nil" + let pr = state.ciStatusByProject[project.id]?.prNumber.map(String.init) ?? "nil" + return "\(project.id.uuidString)|\(branch ?? "nil")|\(dirty)|\(pr)" + } + + func body(content: Content) -> some View { + content + .task(id: refreshKey) { + // Ask the desktop for the (branch-scoped) hook-built menu and + // render exactly what it returns — no items assembled here. + items = (try? await state.fetchProjectMenu(projectId: project.id, branch: branch)) ?? [] + } + .contextMenu { + if items.isEmpty { + Button {} label: { Label("Loading…", systemImage: "hourglass") } + .disabled(true) + } else { + MenuItemsView(items) + .menuActionHandler(handler) + } + } + .projectAutopilotMenuHost( + project: project, + status: $autopilotStatus, + showDownloadSheet: $showingSecretsDownload, + showReleaseCreate: $showingReleaseCreate, + setupChat: $setupChat, + info: $info, + state: state + ) + .mobileAutopilotLoadingDialog( + isRunning, + title: "Working…", + message: "The Mac is performing the requested action." + ) + } + + private var handler: MenuActionHandler { + MenuActionHandler { action in + switch action { + case .command(let command): + run(command) + case .deepLink(let url): + handleDeepLink(url) + } + } + } + + private func run(_ command: MenuActionCommand) { + guard !isRunning else { return } + // Only block behind the loading dialog for async commands; instant + // commands (e.g. stop review) skip the scrim. + let showsLoading = command.isAsync + if showsLoading { isRunning = true } + Task { + defer { if showsLoading { isRunning = false } } + do { + let result = try await state.executeMenuCommand(command) + await state.refreshSnapshot() + if let threadId = result.threadId { + // Navigate into the spawned thread (sidebar → project → thread). + state.pendingDeepLink = MobileDeepLink(sessionID: threadId, projectID: project.id) + } + if let urlString = result.openURL, let url = URL(string: urlString) { + openURL(url) + } + } catch { + info = AutopilotMenuInfo(text: error.localizedDescription, isError: true) + } + } + } + + private func handleDeepLink(_ url: URL) { + switch mobileMenuDeepLinkTarget(url) { + case .secretsDownload: + showingSecretsDownload = true + case .releaseCreate: + showingReleaseCreate = true + case .setupChat(let prompt): + setupChat = AutopilotSetupChat(prompt: prompt) + case nil: + break + } + } +} diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index b5db391e..6af831b3 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -12,11 +12,11 @@ struct MobileBriefingDetailView: View { @Environment(\.openURL) private var openURL @State private var showingNewThread = false @State private var isInitializingGit = false - @State private var isCreatingPR = false - @State private var isCreatingReview = false - @State private var isCommittingProject = false - // Autopilot context menu (1:1 with the desktop briefing/project menu). + // Autopilot context menu — fetched from the desktop as serialized MenuItems + // (the same branch-scoped hook menu the Mac renders). + @State private var menuItems: [MenuItem] = [] + @State private var isRunningMenuCommand = false @State private var autopilotStatus: AutopilotProjectStatus? @State private var showingSecretsDownload = false @State private var showingReleaseCreate = false @@ -55,25 +55,17 @@ struct MobileBriefingDetailView: View { ToolbarItem(placement: .topBarTrailing) { Menu { if let project { - ProjectAutopilotMenuItems( - project: project, - status: autopilotStatus, - showDownloadSheet: $showingSecretsDownload, - showReleaseCreate: $showingReleaseCreate, - setupChat: $autopilotSetupChat, - info: $autopilotInfo, - // Offer "Create PR" for a real branch with no open - // PR yet; once a PR exists the "Open Pull Request" - // link below covers it. - branch: isUnknownBranch ? nil : groupKey.branch, - prNumber: ciStatus?.prNumber, - isCreatingPR: isCreatingPR, - onCreatePR: { createPullRequest(project: project) }, - isCreatingReview: isCreatingReview, - onCodeReview: { createCodeReview(project: project) }, - isCommittingAll: isCommittingProject, - onCommitAll: { commitProjectChanges(project: project) } - ) + // Render the branch-scoped serialized menu the desktop + // returns (Code Review / Create PR target this card's + // branch); commands run on the Mac, deep links open the + // on-device sheet / setup chat. + if menuItems.isEmpty { + Button {} label: { Label("Loading…", systemImage: "hourglass") } + .disabled(true) + } else { + MenuItemsView(menuItems) + .menuActionHandler(menuHandler(project: project)) + } if gitHubURL != nil { Divider() } } if let gitHubURL { @@ -122,20 +114,75 @@ struct MobileBriefingDetailView: View { ]) } .mobileAutopilotLoadingDialog( - isCreatingPR, - title: "Creating Pull Request…", - message: "The Mac is pushing the branch and opening the PR." - ) - .mobileAutopilotLoadingDialog( - isCreatingReview, - title: "Starting Code Review…", - message: "The Mac is starting a Code Review thread for this branch." - ) - .mobileAutopilotLoadingDialog( - isCommittingProject, - title: "Committing Changes…", - message: "The Mac is starting a commit thread for this project." + isRunningMenuCommand, + title: "Working…", + message: "The Mac is performing the requested action." ) + .task(id: menuRefreshKey) { + // Prefetch the branch-scoped serialized menu from the Mac, re-keyed on + // the git-dirty + branch PR state it's gated by so it refreshes after + // commits / PR creation / status polls land in the snapshot. + menuItems = (try? await state.fetchProjectMenu( + projectId: groupKey.projectId, + branch: isUnknownBranch ? nil : groupKey.branch + )) ?? [] + } + } + + /// Invalidation key for the branch-scoped menu prefetch. + private var menuRefreshKey: String { + let branch = isUnknownBranch ? "nil" : groupKey.branch + let dirty = state.projectDirtyByProject[groupKey.projectId].map(String.init) ?? "nil" + let pr = ciStatus?.prNumber.map(String.init) ?? "nil" + return "\(groupKey.projectId.uuidString)|\(branch)|\(dirty)|\(pr)" + } + + // MARK: - Serialized menu handling + + /// Handler installed on the fetched `MenuItem`s: commands run on the Mac via + /// the shared dispatcher; deep links open the matching on-device sheet / setup + /// chat (reusing the autopilot host bindings). + private func menuHandler(project: Project) -> MenuActionHandler { + MenuActionHandler { action in + switch action { + case .command(let command): + runMenuCommand(command) + case .deepLink(let url): + handleMenuDeepLink(url) + } + } + } + + private func runMenuCommand(_ command: MenuActionCommand) { + guard !isRunningMenuCommand else { return } + // Only block behind the loading dialog for async commands (push + open PR, + // code review, …); instant commands skip the scrim. + let showsLoading = command.isAsync + if showsLoading { isRunningMenuCommand = true } + Task { + defer { if showsLoading { isRunningMenuCommand = false } } + do { + let result = try await state.executeMenuCommand(command) + await state.refreshSnapshot() + if let threadId = result.threadId { onOpenSession(threadId) } + if let urlString = result.openURL, let url = URL(string: urlString) { openURL(url) } + } catch { + autopilotInfo = AutopilotMenuInfo(text: error.localizedDescription, isError: true) + } + } + } + + private func handleMenuDeepLink(_ url: URL) { + switch mobileMenuDeepLinkTarget(url) { + case .secretsDownload: + showingSecretsDownload = true + case .releaseCreate: + showingReleaseCreate = true + case .setupChat(let prompt): + autopilotSetupChat = AutopilotSetupChat(prompt: prompt) + case nil: + break + } } private var group: GroupedBriefing? { @@ -200,65 +247,6 @@ struct MobileBriefingDetailView: View { } } - /// Ask the Mac to open a PR for this branch, then open it in the browser. - /// The Mac pushes the branch, drafts the title/body from the briefing, and - /// creates the PR; on failure we surface the reason in the info alert. - private func createPullRequest(project: Project) { - guard !isCreatingPR else { return } - isCreatingPR = true - Task { - defer { isCreatingPR = false } - do { - let url = try await state.requestProjectCreatePullRequest( - projectId: project.id, - branch: groupKey.branch - ) - await state.refreshSnapshot() - openURL(url) - } catch { - autopilotInfo = AutopilotMenuInfo(text: error.localizedDescription, isError: true) - } - } - } - - /// Ask the Mac to start a `[Code Review]` thread reviewing this branch, then - /// open it once it syncs over. The Mac grounds the review in the branch - /// briefing; on failure we surface the reason in the info alert. - private func createCodeReview(project: Project) { - guard !isCreatingReview, !isUnknownBranch else { return } - isCreatingReview = true - Task { - defer { isCreatingReview = false } - do { - let threadId = try await state.requestProjectCreateCodeReview( - projectId: project.id, - branch: groupKey.branch - ) - await state.refreshSnapshot() - onOpenSession(threadId) - } catch { - autopilotInfo = AutopilotMenuInfo(text: error.localizedDescription, isError: true) - } - } - } - - /// Ask the Mac to start a commit-only thread for all project changes, then - /// open it once it syncs over. - private func commitProjectChanges(project: Project) { - guard !isCommittingProject else { return } - isCommittingProject = true - Task { - defer { isCommittingProject = false } - do { - let threadId = try await state.requestProjectCommitAll(projectId: project.id) - await state.refreshSnapshot() - onOpenSession(threadId) - } catch { - autopilotInfo = AutopilotMenuInfo(text: error.localizedDescription, isError: true) - } - } - } - // MARK: - Header Card private var headerCard: some View { @@ -508,13 +496,13 @@ struct MobileBriefingThreadCard: View { .multilineTextAlignment(.leading) if !thread.summary.isEmpty { - // Render the thread description as block markdown (bullets / - // emphasis) instead of inline-only text with raw markers. - MarkdownContentView(text: thread.summary) + // Show only the first few lines of the summary to keep cards compact. + Text(thread.summary) .font(.system(size: 13)) .foregroundStyle(.secondary) + .lineLimit(3) + .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) } if isStreaming { diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index 960e0879..a1109259 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -61,6 +61,7 @@ struct MobileBriefingView: View { } .buttonStyle(.plain) .accessibilityIdentifier("briefing-card-\(group.id)") + .projectRemoteContextMenu(project: projectsById[group.projectId], branch: group.branch) } } } @@ -315,6 +316,7 @@ struct BriefingListView: View { .buttonStyle(BriefingListCardButtonStyle(isSelected: selectedGroup == group.key)) .glassEffectID(group.id, in: glassNamespace) .accessibilityIdentifier("briefing-list-card-\(group.id)") + .projectRemoteContextMenu(project: projectsById[group.projectId], branch: group.branch) } } } diff --git a/RxCodeMobile/Views/MobileChatView+Toolbar.swift b/RxCodeMobile/Views/MobileChatView+Toolbar.swift index ff2b566b..842e68e7 100644 --- a/RxCodeMobile/Views/MobileChatView+Toolbar.swift +++ b/RxCodeMobile/Views/MobileChatView+Toolbar.swift @@ -29,15 +29,19 @@ extension MobileChatView { } label: { Label("View Changes", systemImage: "plus.forwardslash.minus") } - Button { - startCodeReview() - } label: { - Label("Code Review", systemImage: "checklist") - } - Button { - startCommitFiles() - } label: { - Label("Commit Files", systemImage: "checkmark.circle") + if !isCodeReviewThread { + Button { + startCodeReview() + } label: { + Label("Code Review", systemImage: "checklist") + } + if threadHasFileChanges { + Button { + startCommitFiles() + } label: { + Label("Commit Files", systemImage: "checkmark.circle") + } + } } Divider() Button { @@ -115,6 +119,20 @@ extension MobileChatView { && state.sessions.contains(where: { $0.id == sessionID }) } + /// True when the open thread is itself a `[Code Review]` thread — hides the + /// "Code Review" / "Commit Files" actions since you don't review or commit a + /// review thread itself (mirrors the session-list context menu gating). + var isCodeReviewThread: Bool { + state.sessionSummary(sessionID: sessionID)?.isCodeReviewThread ?? false + } + + /// Whether the open thread recorded any file edits — gates "Commit Files". + /// Unknown (summary missing, or an older desktop that didn't sync the count) + /// keeps the action visible. + var threadHasFileChanges: Bool { + state.sessionSummary(sessionID: sessionID)?.hasRecordedFileChanges ?? true + } + /// Start a `[Code Review]` thread reviewing this thread's changes (the manual /// equivalent of the built-in Code Review hook), then deep-link to the new /// review thread once it syncs over from the Mac. diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index 145a82e1..18b7ccab 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -454,6 +454,7 @@ private struct GlassProjectCard: View { .buttonStyle(GlassProjectCardButtonStyle(isSelected: isSelected)) .glassEffectID(project.id.uuidString, in: namespace) .accessibilityIdentifier("project-row-\(project.id.uuidString)") + .projectRemoteContextMenu(project: project) } else { Button { onSelect?() @@ -463,6 +464,7 @@ private struct GlassProjectCard: View { .buttonStyle(GlassProjectCardButtonStyle(isSelected: isSelected)) .glassEffectID(project.id.uuidString, in: namespace) .accessibilityIdentifier("project-row-\(project.id.uuidString)") + .projectRemoteContextMenu(project: project) } } diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index b3dc070e..3ef38c68 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -44,6 +44,13 @@ struct SessionsList: View { @State private var autopilotSetupChat: AutopilotSetupChat? @State private var autopilotInfo: AutopilotMenuInfo? + // Serializable menus fetched from the desktop (the same hook-built items the + // Mac renders). `.contextMenu` builds synchronously, so they're prefetched + // into these caches when the project / each thread appears. + @State private var projectMenuItems: [MenuItem] = [] + @State private var threadMenus: [String: [MenuItem]] = [:] + @State private var isRunningMenuCommand = false + private var project: Project? { state.projects.first { $0.id == projectID } } @@ -150,6 +157,82 @@ struct SessionsList: View { title: "Committing Changes…", message: "The Mac is starting a commit thread for this project." ) + .mobileAutopilotLoadingDialog( + isRunningMenuCommand, + title: "Working…", + message: "The Mac is performing the requested action." + ) + .task(id: projectMenuRefreshKey) { + // Prefetch the project's serializable context menu from the Mac, + // re-keyed on the git-dirty + PR state it's gated by so it + // refreshes after commits / PR creation / status polls. + projectMenuItems = (try? await state.fetchProjectMenu(projectId: projectID)) ?? [] + } + } + + /// Invalidation key for the project menu: changes when the desktop status the + /// menu is conditionally built from changes, so the prefetch re-runs. + private var projectMenuRefreshKey: String { + let dirty = state.projectDirtyByProject[projectID].map(String.init) ?? "nil" + let pr = ciStatus?.prNumber.map(String.init) ?? "nil" + return "\(projectID.uuidString)|\(dirty)|\(pr)" + } + + /// Invalidation key for a thread menu: changes when the thread's + /// file-edit / review-thread gating changes. + private func threadMenuRefreshKey(_ session: SessionSummary) -> String { + "\(session.id)|\(session.hasRecordedFileChanges)|\(session.isCodeReviewThread)" + } + + // MARK: - Serializable menu handling + + /// The handler installed on fetched `MenuItem`s. Commands run on the Mac via + /// the shared dispatcher (`executeMenuCommand`); deep links open the matching + /// on-device sheet / setup chat, reusing the existing autopilot UI. + private func mobileMenuHandler(project: Project) -> MenuActionHandler { + MenuActionHandler { action in + switch action { + case .command(let command): + runMenuCommand(command) + case .deepLink(let url): + handleMenuDeepLink(url, project: project) + } + } + } + + private func runMenuCommand(_ command: MenuActionCommand) { + guard !isRunningMenuCommand else { return } + // Only block behind the loading dialog for async commands; instant + // commands (e.g. stop review) skip the scrim. + let showsLoading = command.isAsync + if showsLoading { isRunningMenuCommand = true } + Task { + defer { if showsLoading { isRunningMenuCommand = false } } + do { + let result = try await state.executeMenuCommand(command) + await state.refreshSnapshot() + if let threadId = result.threadId { selected = threadId } + if let urlString = result.openURL, let url = URL(string: urlString) { openURL(url) } + } catch { + autopilotInfo = AutopilotMenuInfo(text: error.localizedDescription, isError: true) + } + } + } + + /// Map a `rxcode://menu/…` deep link to the existing on-device autopilot UI + /// (sheets / setup chats), so the phone presents its own UI rather than the + /// Mac's. + private func handleMenuDeepLink(_ url: URL, project: Project) { + switch mobileMenuDeepLinkTarget(url) { + case .secretsDownload: + showingSecretsDownload = true + case .releaseCreate: + showingReleaseCreate = true + case .setupChat(let prompt): + autopilotSetupChat = AutopilotSetupChat(prompt: prompt) + case nil: + break + } } /// Ask the Mac to open a PR for the project's current branch, then open it @@ -243,32 +326,16 @@ struct SessionsList: View { if let project { ToolbarItem(placement: .topBarTrailing) { Menu { - ProjectAutopilotMenuItems( - project: project, - status: autopilotStatus, - showDownloadSheet: $showingSecretsDownload, - showReleaseCreate: $showingReleaseCreate, - setupChat: $autopilotSetupChat, - info: $autopilotInfo, - branch: currentBranch, - prNumber: ciStatus?.prNumber, - isCreatingPR: isCreatingPR, - onCreatePR: { - if let branch = currentBranch { - createPullRequest(project: project, branch: branch) - } - }, - isCreatingReview: isCreatingReview, - onCodeReview: { - if let branch = currentBranch { - createBranchCodeReview(project: project, branch: branch) - } - }, - isCommittingAll: isCommittingProject, - onCommitAll: { - commitProjectChanges(project: project) - } - ) + // The project's action items are now fetched from the Mac as + // serializable MenuItems (the same hook-built menu the desktop + // renders) and dispatched through the shared handler. + if projectMenuItems.isEmpty { + Button {} label: { Label("Loading…", systemImage: "hourglass") } + .disabled(true) + } else { + MenuItemsView(projectMenuItems) + .menuActionHandler(mobileMenuHandler(project: project)) + } Divider() Button(role: .destructive) { showingDeleteProjectConfirm = true @@ -348,16 +415,13 @@ struct SessionsList: View { @ViewBuilder private func threadContextMenu(for session: SessionSummary) -> some View { - Button { - createThreadCodeReview(sessionID: session.id) - } label: { - Label("Code Review", systemImage: "checklist") - } - - Button { - commitThreadFiles(sessionID: session.id) - } label: { - Label("Commit Files", systemImage: "checkmark.circle") + // Code review / commit / autopilot items come from the Mac as serializable + // MenuItems (the same hook-built thread menu the desktop renders). + let items = threadMenus[session.id] ?? [] + if !items.isEmpty, let project { + MenuItemsView(items) + .menuActionHandler(mobileMenuHandler(project: project)) + Divider() } Button { @@ -485,6 +549,12 @@ struct SessionsList: View { .onAppear { if indentLevel == 0, session.id == visible.last?.id { loadMore() } } + .task(id: threadMenuRefreshKey(session)) { + // Prefetch this thread's serializable menu from the Mac so the + // synchronous `.contextMenu` builder has it ready. Re-keyed on the + // thread's gating state so it refreshes after new file edits, etc. + threadMenus[session.id] = (try? await state.fetchThreadMenu(sessionId: session.id)) ?? [] + } .contextMenu { threadContextMenu(for: session) }