From a01453f38d246ed6ef05079449a11370ec923d61 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:24:52 +0800 Subject: [PATCH 01/14] fix(thread-changes): exclude Claude plan files from thread changes list Claude plan markdown files (~/.claude/plans/*.md) are planning scaffolding, not real project edits, but were showing up in the thread changes list on both desktop and mobile. Add a shared PlanLogic.isPlanFilePath(_:) predicate (reused by the existing isPlanFileWrite) and filter these out at the two display consumers: threadFileEdits(in:) for the desktop "This Thread" view and handleMobileThreadChangesRequest for the mobile "View Changes" sheet. Filtering at the consumers rather than fetchFileEdits avoids orphaning rows in the rename/delete migration paths that also use that fetch. Co-Authored-By: Claude Opus 4.8 (1M context) --- Packages/Sources/RxCodeChatKit/PlanLogic.swift | 12 ++++++++++-- RxCode/App/AppState+Lifecycle.swift | 4 +++- RxCode/App/AppState+MobileSnapshots.swift | 4 +++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Packages/Sources/RxCodeChatKit/PlanLogic.swift b/Packages/Sources/RxCodeChatKit/PlanLogic.swift index 885d5e53..b111ddce 100644 --- a/Packages/Sources/RxCodeChatKit/PlanLogic.swift +++ b/Packages/Sources/RxCodeChatKit/PlanLogic.swift @@ -29,10 +29,18 @@ public enum PlanLogic { public static func isPlanFileWrite(_ toolCall: ToolCall) -> Bool { guard toolCall.name.lowercased() == "write", - let path = toolCall.input["file_path"]?.stringValue, - path.hasSuffix(".md") else { + let path = toolCall.input["file_path"]?.stringValue else { return false } + return isPlanFilePath(path) + } + + /// True when `path` points at a Claude plan markdown file living under a + /// `.../.claude/plans/` (or `.../claude/plans/`) directory. These plan + /// documents are planning scaffolding, not real project edits, so the + /// thread changes list excludes them. + public static func isPlanFilePath(_ path: String) -> Bool { + guard path.hasSuffix(".md") else { return false } return path.contains("/.claude/plans/") || path.contains("/claude/plans/") } diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift index 32e4a16b..f87d61dd 100644 --- a/RxCode/App/AppState+Lifecycle.swift +++ b/RxCode/App/AppState+Lifecycle.swift @@ -22,7 +22,9 @@ extension AppState { /// Returns an empty array for a not-yet-persisted (placeholder) session. func threadFileEdits(in window: WindowState) -> [FileEditSummary] { let key = window.currentSessionId ?? window.newSessionKey - return threadStore.fetchFileEdits(sessionId: key).map { $0.toSummary() } + return threadStore.fetchFileEdits(sessionId: key) + .map { $0.toSummary() } + .filter { !PlanLogic.isPlanFilePath($0.path) } } func isStreaming(in window: WindowState) -> Bool { diff --git a/RxCode/App/AppState+MobileSnapshots.swift b/RxCode/App/AppState+MobileSnapshots.swift index 328cd32e..0bf85164 100644 --- a/RxCode/App/AppState+MobileSnapshots.swift +++ b/RxCode/App/AppState+MobileSnapshots.swift @@ -750,7 +750,9 @@ extension AppState { let resolvedID = resolveCurrentSessionId(request.sessionID) // This Turn: every file edited in the thread session (SwiftData history). - let editSummaries = threadStore.fetchFileEdits(sessionId: resolvedID).map { $0.toSummary() } + let editSummaries = threadStore.fetchFileEdits(sessionId: resolvedID) + .map { $0.toSummary() } + .filter { !PlanLogic.isPlanFilePath($0.path) } let unboundedEdits = await withTaskGroup(of: (Int, SyncFileEdit).self) { group in for (index, summary) in editSummaries.enumerated() { group.addTask { From a994807f891e5859c0a621019b3d94f7cf23682b Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:53:24 +0800 Subject: [PATCH 02/14] feat(markdown): render user messages and table cells as markdown Render user message bubbles through the markdown renderer (new rxCodeChatUser style tuned for the accent bubble), rewriting [ImageN] chips into rxcode-image:// links so taps still open the image preview, and preserving long-message collapse via height clipping. Also parse table cell contents as inline markdown so bold/code/links/italics render instead of showing literal syntax. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../RxCodeChatKit/ChatMessageBubble.swift | 11 +--- .../Sources/RxCodeChatKit/MarkdownView.swift | 28 ++++++++- .../Sources/RxCodeChatKit/MessageBubble.swift | 61 ++++++++----------- .../Sources/RxCodeMarkdown/MarkdownView.swift | 28 ++++++--- 4 files changed, 75 insertions(+), 53 deletions(-) diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift index 24b6191d..9e900edd 100644 --- a/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift @@ -93,14 +93,9 @@ private struct CompactChatMessageBubble: View { } private func userTextBubble(_ text: String) -> some View { - ChatTextContentView( - text, - size: ClaudeTheme.messageSize(14), - color: ClaudeTheme.userBubbleText, - lineSpacing: 2 - ) - .bubbleStyle(.user) - .frame(maxWidth: 500, alignment: .trailing) + MarkdownContentView(text: text, style: .rxCodeChatUser) + .bubbleStyle(.user) + .frame(maxWidth: 500, alignment: .trailing) } private func assistantText(_ text: String) -> some View { diff --git a/Packages/Sources/RxCodeChatKit/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index 7391528f..957f6362 100644 --- a/Packages/Sources/RxCodeChatKit/MarkdownView.swift +++ b/Packages/Sources/RxCodeChatKit/MarkdownView.swift @@ -3,7 +3,7 @@ import RxCodeCore import RxCodeMarkdown extension MarkdownStyle { - static var rxCodeChat: MarkdownStyle { + public static var rxCodeChat: MarkdownStyle { MarkdownStyle( bodyFontSize: ClaudeTheme.messageSize(15), bodyColor: ClaudeTheme.textPrimary, @@ -19,6 +19,27 @@ extension MarkdownStyle { cornerRadius: ClaudeTheme.cornerRadiusSmall ) } + + /// Markdown styling tuned for the accent-tinted user bubble: text and + /// inline accents derive from `userBubbleText` so they stay legible on the + /// dark bubble background instead of using the global primary/accent colors. + public static var rxCodeChatUser: MarkdownStyle { + let text = ClaudeTheme.userBubbleText + return MarkdownStyle( + bodyFontSize: ClaudeTheme.messageSize(14), + bodyColor: text, + secondaryColor: text.opacity(0.85), + accentColor: text, + codeTextColor: text, + codeBackground: text.opacity(0.12), + codeHeaderBackground: text.opacity(0.08), + borderColor: text.opacity(0.22), + tableHeaderBackground: text.opacity(0.1), + lineSpacing: 3, + blockSpacing: 8, + cornerRadius: ClaudeTheme.cornerRadiusSmall + ) + } } /// Renders markdown text through the pure SwiftUI markdown package while @@ -28,6 +49,7 @@ public struct MarkdownContentView: View { let showsTrailingCursor: Bool let isCursorVisible: Bool let baseURL: URL? + let style: MarkdownStyle let fadeNewText: Bool let onOpenLink: MarkdownView.LinkHandler? @@ -36,6 +58,7 @@ public struct MarkdownContentView: View { showsTrailingCursor: Bool = false, isCursorVisible: Bool = true, baseURL: URL? = nil, + style: MarkdownStyle = .rxCodeChat, fadeNewText: Bool = false, onOpenLink: MarkdownView.LinkHandler? = nil ) { @@ -43,6 +66,7 @@ public struct MarkdownContentView: View { self.showsTrailingCursor = showsTrailingCursor self.isCursorVisible = isCursorVisible self.baseURL = baseURL + self.style = style self.fadeNewText = fadeNewText self.onOpenLink = onOpenLink } @@ -53,7 +77,7 @@ public struct MarkdownContentView: View { showsTrailingCursor: showsTrailingCursor, isCursorVisible: isCursorVisible, baseURL: baseURL, - style: .rxCodeChat, + style: style, fadeNewText: fadeNewText, onOpenLink: onOpenLink ) diff --git a/Packages/Sources/RxCodeChatKit/MessageBubble.swift b/Packages/Sources/RxCodeChatKit/MessageBubble.swift index c76bb375..fa78fa42 100644 --- a/Packages/Sources/RxCodeChatKit/MessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/MessageBubble.swift @@ -19,6 +19,9 @@ struct MessageBubble: View { /// Threshold (character count) for collapsing long text private static let longTextThreshold = 500 + /// Height the collapsed (long) user bubble is clipped to before "Show more". + private static let collapsedMaxHeight: CGFloat = 120 + private enum AssistantRenderBlock: Identifiable { case text(MessageBlock) case tool(ToolCall) @@ -234,24 +237,25 @@ struct MessageBubble: View { } else { VStack(alignment: .leading, spacing: 6) { let isLong = displayText.count > Self.longTextThreshold - ChatTextContentView( - attributed: chipifiedAttributedString(displayText), - size: ClaudeTheme.messageSize(14), - color: ClaudeTheme.userBubbleText, - maximumNumberOfLines: isLong && !isLongTextExpanded ? 5 : nil + let collapsed = isLong && !isLongTextExpanded + MarkdownContentView( + text: markdownUserText(displayText), + style: .rxCodeChatUser ) { url in // Intercept the synthetic `rxcode-image://` link emitted - // by chipifiedAttributedString and open the matching image in the - // preview sheet rather than the system browser. + // by markdownUserText and open the matching image in the preview + // sheet rather than the system browser. guard url.scheme == "rxcode-image", let index = Int(url.host ?? ""), let path = imagePath(forChipIndex: index) else { - return false + return .systemAction } previewImagePath = path - return true + return .handled } - .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: collapsed ? Self.collapsedMaxHeight : nil, alignment: .topLeading) + .clipped() + .fixedSize(horizontal: false, vertical: !collapsed) if isLong { Button { withAnimation(.easeInOut(duration: 0.2)) { @@ -660,32 +664,21 @@ struct MessageBubble: View { return result } - /// Renders `[Image\d+]` tokens with accent-tinted chip styling. The same tokens - /// are inserted into the input bar by `WindowState.insertImageToken` and drawn - /// with a rounded background there via `ChipLayoutManager`; this mirrors that - /// treatment in the sent user bubble. - /// - /// Each chip also gets a `rxcode-image://` link attribute; an - /// `environment(\.openURL, ...)` handler on the Text intercepts the tap and - /// opens the corresponding image in `MessageImagePreviewSheet`. The index - /// matches `WindowState.imageIndex(for:)` — 1-based, image-only. - private func chipifiedAttributedString(_ text: String) -> AttributedString { - var attr = AttributedString(text) + /// Prepares user-message text for the markdown renderer by rewriting `[ImageN]` + /// chip tokens into `[ImageN](rxcode-image://N)` links. The renderer then draws + /// them as tappable accents; the `rxcode-image` scheme is intercepted by the + /// bubble's link handler to open the image preview rather than the browser. The + /// same `[ImageN]` tokens are inserted into the input bar by + /// `WindowState.insertImageToken`; the index matches `WindowState.imageIndex(for:)` + /// — 1-based, image-only. + private func markdownUserText(_ text: String) -> String { let ns = text as NSString let fullRange = NSRange(location: 0, length: ns.length) - Self.imageChipRegex.enumerateMatches(in: text, range: fullRange) { match, _, _ in - guard let m = match, - let range = Range(m.range, in: attr), - m.numberOfRanges >= 2, - let indexRange = Range(m.range(at: 1), in: text), - let index = Int(text[indexRange]) else { return } - attr[range].backgroundColor = ClaudeTheme.accent.opacity(0.22) - attr[range].foregroundColor = ClaudeTheme.accent - attr[range].font = .system(size: ClaudeTheme.messageSize(13), weight: .medium) - attr[range].link = URL(string: "rxcode-image://\(index)") - attr[range].underlineStyle = nil - } - return attr + return Self.imageChipRegex.stringByReplacingMatches( + in: text, + range: fullRange, + withTemplate: "[Image$1](rxcode-image://$1)" + ) } /// Resolve `[ImageN]` chip index (1-based) to a concrete image file path. diff --git a/Packages/Sources/RxCodeMarkdown/MarkdownView.swift b/Packages/Sources/RxCodeMarkdown/MarkdownView.swift index 0bfcc14b..d33f07f6 100644 --- a/Packages/Sources/RxCodeMarkdown/MarkdownView.swift +++ b/Packages/Sources/RxCodeMarkdown/MarkdownView.swift @@ -275,7 +275,7 @@ private struct MarkdownDocumentView: View { ) .opacity(opacity(for: range)) case .table(let headers, let rows, let range): - MarkdownTableView(headers: headers, rows: rows, style: style) + MarkdownTableView(headers: headers, rows: rows, baseURL: baseURL, style: style) .opacity(opacity(for: range)) case .divider(let range): Rectangle() @@ -605,6 +605,7 @@ private struct MarkdownCodeBlockView: View { private struct MarkdownTableView: View { let headers: [String] let rows: [[String]] + let baseURL: URL? let style: MarkdownStyle var body: some View { @@ -612,18 +613,14 @@ private struct MarkdownTableView: View { Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) { GridRow { ForEach(Array(headers.enumerated()), id: \.offset) { _, header in - Text(header) - .font(.system(size: style.bodyFontSize * 0.92, weight: .semibold)) - .foregroundStyle(style.bodyColor) + cell(header, weight: .semibold, color: style.bodyColor) .padding(.vertical, 5) } } ForEach(Array(rows.enumerated()), id: \.offset) { _, row in GridRow { ForEach(0.. some View { + InlineMarkdownText( + inlines: MarkdownDocumentParser.parseInlines(content), + style: style, + baseURL: baseURL, + fontSize: style.bodyFontSize * 0.92, + weight: weight, + overrideColor: color, + fadeSegments: [] + ) + } } private struct CachedMarkdownImage: View { @@ -763,8 +773,8 @@ private func copyToPasteboard(_ text: String) { | Component | Status | | --- | --- | - | Parser | Native | - | Images | Cached | + | **Parser** | `Native` | + | [Images](https://rxlab.dev) | *Cached* | ```swift MarkdownView(text: markdown, fadeNewText: true) From c4661991017371a71286c51c97af60e0d14ffb51 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:54:35 +0800 Subject: [PATCH 03/14] fix(message-list): keep bottom anchor sticky through tall-card layout settle A tall card (Edit diff, Bash output) that lays out in one frame grows the content height while the throttled/async scroll-to-bottom is still pending, leaving distanceFromBottom huge. A non-user-driven stable geometry frame in that window would recompute isNearBottom to false even though the user never scrolled, causing the pending auto-scroll to bail and stranding the view above the bottom. Gate the un-stick recompute in MessageListScrollAnchor.apply on a new isUserDriven flag so only a genuine user scroll releases the anchor; layout settles keep it sticky. Preserves the "only follow the bottom while at the bottom" semantics. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/MessageList/MessageList.swift | 3 +- .../MessageList/MessageListScrollAnchor.swift | 19 ++++++- .../MessageListScrollAnchorTests.swift | 51 ++++++++++++++++--- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/Packages/Sources/MessageList/MessageList.swift b/Packages/Sources/MessageList/MessageList.swift index 00428cbc..3e559217 100644 --- a/Packages/Sources/MessageList/MessageList.swift +++ b/Packages/Sources/MessageList/MessageList.swift @@ -277,7 +277,8 @@ public struct MessageList: View { private func handleScrollMetrics(_ metrics: MessageListScrollMetrics, proxy: ScrollViewProxy) { let decision = anchor.apply( contentHeight: metrics.contentHeight, - visibleMaxY: metrics.visibleMaxY + visibleMaxY: metrics.visibleMaxY, + isUserDriven: isUserDrivenScroll ) updateIsAtBottomBinding(anchor.isNearBottom) diff --git a/Packages/Sources/MessageList/MessageListScrollAnchor.swift b/Packages/Sources/MessageList/MessageListScrollAnchor.swift index af26c694..91ba467f 100644 --- a/Packages/Sources/MessageList/MessageListScrollAnchor.swift +++ b/Packages/Sources/MessageList/MessageListScrollAnchor.swift @@ -18,8 +18,14 @@ nonisolated struct MessageListScrollAnchor: Equatable { self.hasReceivedFirstUpdate = false } + /// Apply a new scroll geometry sample. + /// + /// `isUserDriven` reports whether the change is driven by the user's finger / + /// trackpad (or post-flick glide) rather than by layout or a programmatic + /// scroll. It gates the only branch that can *un-stick* the anchor — see + /// below for why that matters. @discardableResult - mutating func apply(contentHeight: CGFloat, visibleMaxY: CGFloat) -> Decision { + mutating func apply(contentHeight: CGFloat, visibleMaxY: CGFloat, isUserDriven: Bool) -> Decision { let distanceFromBottom = max(0, contentHeight - visibleMaxY) let nowNearBottom = distanceFromBottom < threshold @@ -39,6 +45,17 @@ nonisolated struct MessageListScrollAnchor: Equatable { return previouslyNearBottom ? .scrollToBottom : .none } + // Content stable (or shrunk). Recomputing `isNearBottom` from the raw + // distance is the ONLY path that can un-stick the anchor, so it must + // only run for a genuine user scroll. A tall card (Edit diff, Bash + // output, etc.) that lays out in one frame leaves `distanceFromBottom` + // huge until the throttled/async scroll-to-bottom executes; a layout + // settle that lands in that window would otherwise recompute + // `isNearBottom` to false even though the user never scrolled — which + // then makes the pending auto-scroll bail and strands the view above + // the bottom. Gating on `isUserDriven` keeps the anchor sticky through + // that settle while still letting a deliberate scroll up release it. + guard isUserDriven else { return .none } isNearBottom = nowNearBottom return .none } diff --git a/Packages/Tests/MessageListTests/MessageListScrollAnchorTests.swift b/Packages/Tests/MessageListTests/MessageListScrollAnchorTests.swift index 5c050321..72f3ce09 100644 --- a/Packages/Tests/MessageListTests/MessageListScrollAnchorTests.swift +++ b/Packages/Tests/MessageListTests/MessageListScrollAnchorTests.swift @@ -7,9 +7,9 @@ struct MessageListScrollAnchorTests { @Test("Content growth while anchored requests bottom scroll") func contentGrowthWhileAnchoredRequestsBottomScroll() { var anchor = MessageListScrollAnchor(threshold: 120) - _ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000) + _ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000, isUserDriven: false) - let decision = anchor.apply(contentHeight: 1400, visibleMaxY: 1000) + let decision = anchor.apply(contentHeight: 1400, visibleMaxY: 1000, isUserDriven: false) #expect(decision == .scrollToBottom) #expect(anchor.isNearBottom) @@ -18,10 +18,11 @@ struct MessageListScrollAnchorTests { @Test("Content growth while scrolled up does not re-anchor") func contentGrowthWhileScrolledUpDoesNotReanchor() { var anchor = MessageListScrollAnchor(threshold: 120) - _ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000) - _ = anchor.apply(contentHeight: 1000, visibleMaxY: 600) + _ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000, isUserDriven: false) + // User scrolls up — a user-driven stable frame un-sticks the anchor. + _ = anchor.apply(contentHeight: 1000, visibleMaxY: 600, isUserDriven: true) - let decision = anchor.apply(contentHeight: 1400, visibleMaxY: 600) + let decision = anchor.apply(contentHeight: 1400, visibleMaxY: 600, isUserDriven: false) #expect(decision == .none) #expect(!anchor.isNearBottom) @@ -30,12 +31,48 @@ struct MessageListScrollAnchorTests { @Test("Reset restores bottom anchoring") func resetRestoresBottomAnchoring() { var anchor = MessageListScrollAnchor(threshold: 120) - _ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000) - _ = anchor.apply(contentHeight: 1000, visibleMaxY: 500) + _ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000, isUserDriven: false) + _ = anchor.apply(contentHeight: 1000, visibleMaxY: 500, isUserDriven: true) #expect(!anchor.isNearBottom) anchor.resetToBottom() #expect(anchor.isNearBottom) } + + @Test("Tall card layout settle does not un-stick the anchor") + func tallCardLayoutSettleKeepsAnchorSticky() { + var anchor = MessageListScrollAnchor(threshold: 120) + // User is following the bottom. + _ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000, isUserDriven: false) + + // A tall Edit card lays out in one frame: content grows but the visible + // rect hasn't been re-anchored yet, so distance is huge. Still sticky; + // schedules a scroll-to-bottom. + let growth = anchor.apply(contentHeight: 1800, visibleMaxY: 1000, isUserDriven: false) + #expect(growth == .scrollToBottom) + #expect(anchor.isNearBottom) + + // Before the (async/throttled) scroll executes, a *non*-user-driven + // stable frame arrives while distance is still huge. This must NOT + // un-stick the anchor — otherwise the pending auto-scroll bails and the + // view is stranded above the bottom. + let settle = anchor.apply(contentHeight: 1800, visibleMaxY: 1000, isUserDriven: false) + #expect(settle == .none) + #expect(anchor.isNearBottom) + } + + @Test("User scroll up still releases the anchor after a tall card") + func userScrollUpReleasesAnchorAfterTallCard() { + var anchor = MessageListScrollAnchor(threshold: 120) + _ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000, isUserDriven: false) + _ = anchor.apply(contentHeight: 1800, visibleMaxY: 1000, isUserDriven: false) + // Layout settles to the bottom (visible rect now catches up). + _ = anchor.apply(contentHeight: 1800, visibleMaxY: 1800, isUserDriven: false) + #expect(anchor.isNearBottom) + + // User deliberately scrolls up. + _ = anchor.apply(contentHeight: 1800, visibleMaxY: 1200, isUserDriven: true) + #expect(!anchor.isNearBottom) + } } From e47fa01356fac1830a1e4b394d4565a12536519f Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:02:49 +0800 Subject: [PATCH 04/14] fix(sidebar): preserve review-thread linkage when saving finished thread saveSession rebuilt the ChatSession without carrying over parentThreadId, threadLabel, and skipHooks. When a [Code Review] child thread finished streaming, the re-save overwrote both the in-memory summary and the persisted row with linkage-less values, un-nesting the review from its parent. With zero children, the sidebar disclosure control returned nil and disappeared (and stayed gone across reloads since the store row was overwritten too). Carry the linkage fields through in saveSession so a finished review keeps its parent link and the collapse control remains. Co-Authored-By: Claude Opus 4.8 (1M context) --- RxCode/App/AppState+Helpers.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/RxCode/App/AppState+Helpers.swift b/RxCode/App/AppState+Helpers.swift index a642e7f8..7e15cad6 100644 --- a/RxCode/App/AppState+Helpers.swift +++ b/RxCode/App/AppState+Helpers.swift @@ -263,7 +263,13 @@ extension AppState { worktreePath: summary?.worktreePath, worktreeBranch: summary?.worktreeBranch, isArchived: summary?.isArchived ?? false, - archivedAt: summary?.archivedAt + archivedAt: summary?.archivedAt, + // Preserve review-thread linkage. Without this, re-saving a finished + // `[Code Review]` child wipes its `parentThreadId`, un-nesting it from + // the parent and making the sidebar disclosure control disappear. + parentThreadId: summary?.parentThreadId, + threadLabel: summary?.threadLabel, + skipHooks: summary?.skipHooks ?? false ) do { From 073efe136d292a0d71ac265e1c5ae0b8c7d6d1db Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:28:48 +0800 Subject: [PATCH 05/14] feat(code-review): add manual code review menus across briefing, project, and thread Add a manual "Code Review" action that spawns a [Code Review] thread, mirroring the built-in Code Review hook but user-triggered. Two host functions in AppState+CodeReview: branch-level review grounds the reviewer in the branch briefing + thread summaries; thread-level review nests under a thread and reviews its changed files. Surfaces: - macOS: briefing card menu, project sidebar menu ("Code Review for Current Branch"), and per-thread row context menu. - iOS/Android: briefing detail + project menus (branch) and per-thread menus. Mobile relays via two new desktop-mediated autopilot ops (projectCreateCodeReview / threadCreateCodeReview) with AutopilotThreadBody and AutopilotCodeReviewResult; the Mac runs the review and returns the new thread id so the phone can navigate to it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Protocol/Payload+Autopilot.swift | 21 ++ RxCode/App/AppState+CodeReview.swift | 191 ++++++++++++++++++ RxCode/App/AppState+MobileAutopilot.swift | 18 ++ RxCode/Views/Sidebar/BriefingView.swift | 21 ++ RxCode/Views/Sidebar/ProjectChatRow.swift | 63 +++++- RxCode/Views/Sidebar/ProjectTreeView.swift | 126 +++++++++++- .../app/rxlab/rxcode/proto/AutopilotModels.kt | 4 + .../rxlab/rxcode/proto/AutopilotPayloads.kt | 10 +- .../rxlab/rxcode/state/AutopilotService.kt | 28 +++ .../app/rxlab/rxcode/state/MobileAppState.kt | 8 + .../rxcode/ui/autopilot/ProjectActionsMenu.kt | 45 +++++ .../rxcode/ui/sessions/SessionsScreen.kt | 154 ++++++++++++-- .../State/MobileAppState+Autopilot.swift | 20 ++ .../Autopilot/ProjectAutopilotMenu.swift | 20 ++ .../Views/MobileBriefingDetailView.swift | 31 ++- RxCodeMobile/Views/SessionsList.swift | 184 +++++++++++++++-- 16 files changed, 900 insertions(+), 44 deletions(-) create mode 100644 RxCode/App/AppState+CodeReview.swift diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift index 0b22c692..a1431d80 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -114,6 +114,12 @@ public enum AutopilotOp: String, Codable, Sendable { case projectSecretsDownload case projectSecretsWrite case projectCreatePullRequest + // Code review (desktop-mediated): spawn a `[Code Review]` thread on the Mac. + // `projectCreateCodeReview` reviews a whole branch grounded in its briefing; + // `threadCreateCodeReview` reviews a single thread's changes (the manual + // equivalent of the built-in Code Review hook). + case projectCreateCodeReview + case threadCreateCodeReview // Global search — one call returns on-device thread matches AND published // docs matches for the same query, so mobile gets a single combined result @@ -500,6 +506,21 @@ public struct AutopilotPullRequestResult: Codable, Sendable { public init(url: String) { self.url = url } } +/// Addresses a single thread by id. Used by `threadCreateCodeReview`, where the +/// desktop spawns a `[Code Review]` thread reviewing that thread's changes +/// (the manual equivalent of the built-in Code Review hook). +public struct AutopilotThreadBody: Codable, Sendable { + public let sessionId: String + public init(sessionId: String) { self.sessionId = sessionId } +} + +/// Result of `projectCreateCodeReview` / `threadCreateCodeReview`: the id of the +/// spawned `[Code Review]` thread, so the phone can navigate to it once it syncs. +public struct AutopilotCodeReviewResult: Codable, Sendable { + public let threadId: String + public init(threadId: String) { self.threadId = threadId } +} + /// 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/RxCode/App/AppState+CodeReview.swift b/RxCode/App/AppState+CodeReview.swift new file mode 100644 index 00000000..503c0b89 --- /dev/null +++ b/RxCode/App/AppState+CodeReview.swift @@ -0,0 +1,191 @@ +import Foundation +import RxCodeCore + +/// Errors surfaced while starting a manual code review from a briefing card, +/// project menu, or thread row. +enum CodeReviewError: LocalizedError { + case unknownThread + case unknownProject + case sendFailed(String) + + var errorDescription: String? { + switch self { + case .unknownThread: + return "Couldn't find the thread to review." + case .unknownProject: + return "Couldn't find the project to review." + case .sendFailed(let message): + return "Couldn't start the code review.\n\n\(message)" + } + } +} + +extension AppState { + + /// Label stamped on manually-started review threads (matches the built-in + /// Code Review hook so they show the same `[Code Review]` chip and nest in + /// the sidebar review UI). + static let manualCodeReviewLabel = "Code Review" + + // MARK: - Branch-level review + + /// Start a `[Code Review]` thread that reviews *all* the changes on `branch`, + /// grounded in the branch briefing and the summaries of every thread that ran + /// on it. The reviewer inspects the branch diff itself (it runs in `.auto` + /// mode), so no diff needs to be computed here. Returns the new thread id. + @discardableResult + func createCodeReviewForBranch(project: Project, branch: String) async throws -> String { + let briefing = threadStore.allBranchBriefingItems() + .first(where: { $0.projectId == project.id && $0.branch == branch })? + .briefing ?? "" + let summaries = threadStore.allThreadSummaryItems() + .filter { $0.projectId == project.id && $0.branch == branch } + .sorted { $0.updatedAt > $1.updatedAt } + let prompt = Self.branchCodeReviewPrompt(branch: branch, briefing: briefing, summaries: summaries) + return try await startCodeReviewThread(projectId: project.id, parentThreadId: nil, prompt: prompt) + } + + // MARK: - Thread-level review + + /// Start a `[Code Review]` thread nested under `sessionId` that reviews the + /// files that thread changed — the manual equivalent of the built-in Code + /// Review hook. Returns the new thread id. + @discardableResult + func createCodeReviewForThread(sessionId: String) async throws -> String { + guard let summary = allSessionSummaries.first(where: { $0.id == sessionId }) + ?? threadStore.fetch(id: sessionId)?.toSummary() else { + throw CodeReviewError.unknownThread + } + guard let project = projects.first(where: { $0.id == summary.projectId }) else { + throw CodeReviewError.unknownProject + } + + // Files this thread touched, de-duplicated in first-seen order. + var seen = Set() + var changedFiles: [String] = [] + for edit in threadStore.fetchFileEdits(sessionId: sessionId) where seen.insert(edit.path).inserted { + changedFiles.append(edit.path) + } + + // Pull the task/response from in-memory state when the thread is loaded; + // fall back to the thread title (always available) for an idle thread + // whose messages aren't currently in memory. + let messages = stateForSession(sessionId).messages + let task = messages.first(where: { + $0.role == .user && !$0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + })?.content ?? summary.title + let finalResponse = lastAssistantResponseText(in: messages) + + let prompt = Self.threadCodeReviewPrompt( + task: task, + changedFiles: changedFiles, + finalResponse: finalResponse + ) + return try await startCodeReviewThread(projectId: project.id, parentThreadId: sessionId, prompt: prompt) + } + + // MARK: - Shared launch + + /// Spawn the review thread through the normal send pipeline. Runs in `.auto` + /// mode (so the reviewer can read files / run `git diff` without per-tool + /// prompts) with hooks skipped (the review thread shouldn't trigger its own + /// review). Fire-and-forget — returns as soon as the thread id is known so + /// the caller can navigate to it while the review runs. + private func startCodeReviewThread( + projectId: UUID, + parentThreadId: String?, + prompt: String + ) async throws -> String { + let result = try await sendCrossProject( + projectId: projectId, + threadId: nil, + prompt: prompt, + permissionMode: .auto, + waitForResponse: false, + timeoutSeconds: 600, + parentThreadId: parentThreadId, + threadLabel: Self.manualCodeReviewLabel, + skipHooks: true + ) + if let error = result.error { throw CodeReviewError.sendFailed(error) } + return result.threadId + } + + // MARK: - Prompts + + private static let reviewMarker = "REVIEW_RESULT:" + + static func branchCodeReviewPrompt( + branch: String, + briefing: String, + summaries: [ThreadSummaryItem] + ) -> String { + let trimmedBriefing = briefing.trimmingCharacters(in: .whitespacesAndNewlines) + let briefingSection = trimmedBriefing.isEmpty ? "(no briefing recorded)" : trimmedBriefing + let threadList: String + if summaries.isEmpty { + threadList = "(no thread summaries recorded)" + } else { + threadList = summaries.map { item in + let title = item.title.trimmingCharacters(in: .whitespacesAndNewlines) + let summary = item.summary + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "\n").first.map(String.init) ?? "" + return summary.isEmpty ? "- \(title)" : "- \(title) — \(summary)" + }.joined(separator: "\n") + } + + return """ + You are reviewing all the code changes on branch `\(branch)` in this repository. Do not edit any files — only review. + + ## What this branch set out to do (briefing) + \(briefingSection) + + ## Threads that ran on this branch + \(threadList) + + ## What to do + 1. Determine the branch's base (usually the repository's default branch, e.g. `main`) and inspect the actual diff. For example run `git diff $(git merge-base HEAD main)...HEAD --stat` then read the changed files, or `git diff main...HEAD`. + 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). + + End your reply with a single line — exactly one of: + `\(reviewMarker) PASS` (the changes look good as-is) + `\(reviewMarker) FAIL` (changes are needed) + """ + } + + static func threadCodeReviewPrompt( + task: String, + changedFiles: [String], + finalResponse: String + ) -> String { + let fileList = changedFiles.isEmpty + ? "(no recorded file edits — inspect the working tree / recent commits for what changed)" + : changedFiles.map { "- \($0)" }.joined(separator: "\n") + let response = finalResponse.trimmingCharacters(in: .whitespacesAndNewlines) + let responseSection = response.isEmpty ? "(no final response recorded)" : response + + return """ + You are reviewing another agent's code change in this repository. Do not edit any files — only review. + + ## The user's task + \(task) + + ## Files the agent changed + \(fileList) + + ## The agent's final response + \(responseSection) + + ## 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. + + 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. + """ + } +} diff --git a/RxCode/App/AppState+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift index 5eb0fb07..5c3b10d0 100644 --- a/RxCode/App/AppState+MobileAutopilot.swift +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -338,6 +338,24 @@ extension AppState { let url = try await createPullRequestForBranch(project: project, branch: body.branch) return try encoder.encode(AutopilotPullRequestResult(url: url.absoluteString)) + case .projectCreateCodeReview: + // Same as the desktop briefing/project "Code Review" action: spawn a + // `[Code Review]` thread reviewing the whole branch, grounded in its + // briefing. 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 createCodeReviewForBranch(project: project, branch: body.branch) + return try encoder.encode(AutopilotCodeReviewResult(threadId: threadId)) + + case .threadCreateCodeReview: + // Manual equivalent of the built-in Code Review hook for a single + // thread: spawn a `[Code Review]` thread nested under it. + let body = try decodeAutopilotBody(request, as: AutopilotThreadBody.self) + let threadId = try await createCodeReviewForThread(sessionId: body.sessionId) + return try encoder.encode(AutopilotCodeReviewResult(threadId: threadId)) + case .projectSecretsDownload: let body = try decodeAutopilotBody(request, as: AutopilotProjectSecretsDownloadBody.self) guard let project = projects.first(where: { $0.id == body.projectId }) else { diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index f21a4df2..1f53b5b7 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -627,6 +627,19 @@ struct BriefingView: View { && appState.ciStatusByProject[group.projectId]?.prNumber != nil } + /// Start a `[Code Review]` thread reviewing the whole branch (grounded in + /// its briefing) and open it, mirroring the project/thread review menus. + private func startCodeReview(for group: BriefingGroup, project: Project) { + Task { + if windowState.selectedProject?.id != project.id { + appState.selectProject(project, in: windowState) + } + if let threadId = try? await appState.createCodeReviewForBranch(project: project, branch: group.branch) { + appState.selectSession(id: threadId, in: windowState) + } + } + } + private func cardMenu(for group: BriefingGroup, project: Project) -> some View { Menu { Button { @@ -646,6 +659,14 @@ struct BriefingView: View { Label("Open Project", systemImage: "folder") } + Divider() + + Button { + startCodeReview(for: group, project: project) + } label: { + Label("Code Review for \(group.branch)", systemImage: "checklist") + } + let hookItems = appState.projectContextMenuItems(for: project) if !hookItems.isEmpty { Divider() diff --git a/RxCode/Views/Sidebar/ProjectChatRow.swift b/RxCode/Views/Sidebar/ProjectChatRow.swift index 45dc2f1c..29a2697b 100644 --- a/RxCode/Views/Sidebar/ProjectChatRow.swift +++ b/RxCode/Views/Sidebar/ProjectChatRow.swift @@ -74,6 +74,17 @@ struct StatusBadgeDot: View { // MARK: - ProjectChatRow struct ProjectChatRow: View { + /// Leading disclosure control shown on a thread that has nested review + /// children (the `[Code Review]` threads spawned from it). + struct ReviewDisclosure { + let count: Int + let isExpanded: Bool + /// True while at least one review child is still streaming — surfaces + /// "this thread is being code-reviewed" on the parent row. + let isReviewing: Bool + let onToggle: () -> Void + } + let summary: ChatSession.Summary let isCurrent: Bool let status: ChatStatus @@ -83,7 +94,16 @@ struct ProjectChatRow: View { let onTogglePin: () -> Void let onToggleArchive: () -> Void let onDelete: () -> Void + let onCodeReview: () -> Void let hookMenuItems: [HookMenuItem] + /// 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). + var titleOverride: String? = nil + /// Whether to show the `threadLabel` chip (hidden on review children since + /// the nesting already conveys what they are). + var showLabelChip: Bool = true + var reviewDisclosure: ReviewDisclosure? = nil @State private var isHovered = false @@ -97,6 +117,7 @@ struct ProjectChatRow: View { /// Title cleaned of `[Attached image: ...]` / `[ImageN]` / etc. markers that may /// be baked into older persisted summaries from before title stripping landed. private var displayTitle: String { + if let titleOverride, !titleOverride.isEmpty { return titleOverride } let cleaned = ChatSession.stripAttachmentMarkers(from: summary.title) let resolved = cleaned.isEmpty ? ChatSession.defaultTitle : cleaned return resolved.prefix(1).uppercased() + resolved.dropFirst() @@ -104,6 +125,10 @@ struct ProjectChatRow: View { var body: some View { HStack(spacing: 8) { + if let disclosure = reviewDisclosure { + reviewDisclosureControl(disclosure) + } + if isActiveStatus { statusIndicator } @@ -114,7 +139,7 @@ struct ProjectChatRow: View { .lineLimit(1) .truncationMode(.tail) - if let label = summary.threadLabel, !label.isEmpty { + if showLabelChip, let label = summary.threadLabel, !label.isEmpty { Text(label) .font(.system(size: ClaudeTheme.size(9), weight: .semibold)) .foregroundStyle(ClaudeTheme.accent) @@ -143,7 +168,7 @@ struct ProjectChatRow: View { .frame(width: 28, alignment: .trailing) } } - .padding(.leading, 18) + .padding(.leading, 18 + CGFloat(indentLevel) * 18) .padding(.trailing, 14) .padding(.vertical, 7) .background( @@ -179,6 +204,10 @@ struct ProjectChatRow: View { Label("Archive", systemImage: "archivebox") } } + Divider() + Button { onCodeReview() } label: { + Label("Code Review", systemImage: "checklist") + } if !hookMenuItems.isEmpty { Divider() HookContextMenuItems(items: hookMenuItems) @@ -200,6 +229,36 @@ struct ProjectChatRow: View { } } + /// Leading chevron that expands/collapses the nested review children, plus a + /// review count / "reviewing" spinner. + @ViewBuilder + private func reviewDisclosureControl(_ disclosure: ReviewDisclosure) -> some View { + Button(action: disclosure.onToggle) { + HStack(spacing: 3) { + Image(systemName: disclosure.isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: ClaudeTheme.size(9), weight: .semibold)) + .frame(width: 10, height: 10) + if disclosure.isReviewing { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.mini) + .scaleEffect(0.7) + .frame(width: 10, height: 10) + } else { + Text("\(disclosure.count)") + .font(.system(size: ClaudeTheme.size(9), weight: .semibold)) + .monospacedDigit() + } + } + .foregroundStyle(ClaudeTheme.textTertiary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(disclosure.isReviewing + ? "Code review in progress" + : (disclosure.isExpanded ? "Hide code reviews" : "Show \(disclosure.count) code review(s)")) + } + private static func compactElapsedTime(since date: Date, now: Date = Date()) -> String { let seconds = max(0, Int(now.timeIntervalSince(date))) if seconds < 60 { return "0m" } diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index 63fbc19c..03984d51 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -227,6 +227,18 @@ struct ProjectTreeView: View { .frame(maxWidth: .infinity) } + /// Resolve the project's current branch and start a `[Code Review]` thread + /// reviewing it (grounded in its briefing), then open the new thread. + private func startBranchCodeReview(for project: Project) { + Task { + appState.selectProject(project, in: windowState) + guard let branch = await GitHelper.currentBranch(at: project.path), !branch.isEmpty else { return } + if let threadId = try? await appState.createCodeReviewForBranch(project: project, branch: branch) { + appState.selectSession(id: threadId, in: windowState) + } + } + } + // MARK: - Project List private var projectList: some View { @@ -259,6 +271,9 @@ struct ProjectTreeView: View { appState.selectProject(project, in: windowState) appState.startNewChat(in: windowState) }, + onCodeReview: { + startBranchCodeReview(for: project) + }, hookMenuItems: appState.projectContextMenuItems(for: project) ) @@ -305,6 +320,7 @@ private struct ProjectTreeRow: View { let onRename: () -> Void let onDelete: () -> Void let onNewChat: () -> Void + let onCodeReview: () -> Void let hookMenuItems: [HookMenuItem] @State private var isHovered = false @@ -450,6 +466,9 @@ 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") + } if canCreatePR { Button { startCreatePR() } label: { Label(creatingPR ? "Creating Pull Request…" : "Create Pull Request", @@ -506,8 +525,12 @@ private struct ProjectChatsList: View { let onDeleteSession: (ChatSession) -> Void @State private var showsAllThreads = false + /// Parent thread ids whose nested review children are currently expanded. + @State private var expandedReviewParentIds: Set = [] - private var sessions: [ChatSession.Summary] { + /// All non-archived threads for this project, used as the source for the + /// parent/child split below. + private var allSessions: [ChatSession.Summary] { HistoryListView.filteredSummaries( from: appState.allSessionSummaries, projectId: project.id, @@ -515,6 +538,30 @@ private struct ProjectChatsList: View { ) } + /// Top-level threads only — review children (`parentThreadId` pointing at a + /// thread that is also in this list) are nested under their parent instead. + /// A child whose parent isn't here (archived/elsewhere) falls back to the + /// top level so it's never hidden. + private var sessions: [ChatSession.Summary] { + let ids = Set(allSessions.map(\.id)) + return allSessions.filter { summary in + guard let parent = summary.parentThreadId else { return true } + return !ids.contains(parent) + } + } + + /// Review children grouped by their parent thread id, oldest first so they + /// number naturally as `Review 1`, `Review 2`, … + private var childrenByParent: [String: [ChatSession.Summary]] { + let ids = Set(allSessions.map(\.id)) + let children = allSessions.filter { summary in + guard let parent = summary.parentThreadId else { return false } + return ids.contains(parent) + } + return Dictionary(grouping: children, by: { $0.parentThreadId! }) + .mapValues { $0.sorted { $0.updatedAt < $1.updatedAt } } + } + private var collapsedVisibleCount: Int { let pinnedCount = sessions.prefix(while: { $0.isPinned }).count // Cap pinned slots at 6, then guarantee at least 4 unpinned rows so @@ -545,7 +592,7 @@ private struct ProjectChatsList: View { .padding(.vertical, 4) } else { ForEach(visibleSessions) { summary in - chatRow(for: summary) + threadGroup(for: summary) .transition(.asymmetric( insertion: .move(edge: .top).combined(with: .opacity), removal: .move(edge: .top).combined(with: .opacity) @@ -560,6 +607,60 @@ private struct ProjectChatsList: View { } .clipped() .animation(.easeInOut(duration: 0.18), value: showsAllThreads) + .animation(.easeInOut(duration: 0.18), value: expandedReviewParentIds) + } + + /// A top-level thread row plus, when expanded, its nested review children. + @ViewBuilder + private func threadGroup(for summary: ChatSession.Summary) -> some View { + let children = childrenByParent[summary.id] ?? [] + let isExpanded = expandedReviewParentIds.contains(summary.id) + + VStack(alignment: .leading, spacing: 0) { + chatRow(for: summary, reviewDisclosure: disclosure(for: summary, children: children)) + + if isExpanded { + ForEach(Array(children.enumerated()), id: \.element.id) { index, child in + chatRow( + for: child, + indentLevel: 1, + titleOverride: "Review \(index + 1)", + showLabelChip: false + ) + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + )) + } + } + } + } + + private func disclosure( + for summary: ChatSession.Summary, + children: [ChatSession.Summary] + ) -> ProjectChatRow.ReviewDisclosure? { + guard !children.isEmpty else { return nil } + let isReviewing = children.contains { child in + if case .streaming = appState.chatStatus(forSessionId: child.id, in: windowState) { + return true + } + return false + } + return ProjectChatRow.ReviewDisclosure( + count: children.count, + isExpanded: expandedReviewParentIds.contains(summary.id), + isReviewing: isReviewing, + onToggle: { + withAnimation(.easeInOut(duration: 0.18)) { + if expandedReviewParentIds.contains(summary.id) { + expandedReviewParentIds.remove(summary.id) + } else { + expandedReviewParentIds.insert(summary.id) + } + } + } + ) } private var threadLimitToggle: some View { @@ -593,7 +694,13 @@ private struct ProjectChatsList: View { .help(showsAllThreads ? "Show only the first five threads" : "Show all threads in this project") } - private func chatRow(for summary: ChatSession.Summary) -> some View { + private func chatRow( + for summary: ChatSession.Summary, + indentLevel: Int = 0, + titleOverride: String? = nil, + showLabelChip: Bool = true, + reviewDisclosure: ProjectChatRow.ReviewDisclosure? = nil + ) -> some View { let sessionId = summary.id let session = summary.makeSession() let status = appState.chatStatus(forSessionId: sessionId, in: windowState) @@ -621,7 +728,18 @@ private struct ProjectChatsList: View { onDelete: { onDeleteSession(session) }, - hookMenuItems: appState.threadContextMenuItems(for: summary) + onCodeReview: { + Task { + if let threadId = try? await appState.createCodeReviewForThread(sessionId: sessionId) { + appState.selectSession(id: threadId, in: windowState) + } + } + }, + hookMenuItems: appState.threadContextMenuItems(for: summary), + indentLevel: indentLevel, + titleOverride: titleOverride, + showLabelChip: showLabelChip, + reviewDisclosure: reviewDisclosure ) } } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt index 205b044f..9cd01397 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt @@ -505,6 +505,10 @@ data class AutopilotProjectStatus( @Serializable data class AutopilotPullRequestResult(val url: String) +/** Result of `projectCreateCodeReview` / `threadCreateCodeReview`: the id of the spawned `[Code Review]` thread. */ +@Serializable +data class AutopilotCodeReviewResult(val threadId: String) + /** Result of `projectSecretsWrite`: files written and any skipped conflicts. */ @Serializable data class AutopilotProjectSecretsDownloadResult( 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 b0fed8fa..da059980 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 @@ -106,6 +106,10 @@ enum class AutopilotOp(val wire: String) { PROJECT_SECRETS_WRITE("projectSecretsWrite"), PROJECT_CREATE_PULL_REQUEST("projectCreatePullRequest"), + // Code review (desktop-mediated): spawn a `[Code Review]` thread on the Mac. + PROJECT_CREATE_CODE_REVIEW("projectCreateCodeReview"), + THREAD_CREATE_CODE_REVIEW("threadCreateCodeReview"), + // Global search — one call returns thread matches AND published-docs matches. SEARCH_THREADS_AND_DOCS("searchThreadsAndDocs"), } @@ -228,13 +232,17 @@ data class AutopilotProjectBody( @Serializable(with = UuidSerializer::class) val projectId: UUID, ) -/** Addresses a project + branch. Used by `projectCreatePullRequest`. */ +/** Addresses a project + branch. Used by `projectCreatePullRequest` and `projectCreateCodeReview`. */ @Serializable data class AutopilotProjectBranchBody( @Serializable(with = UuidSerializer::class) val projectId: UUID, val branch: String, ) +/** Addresses a single thread by id. Used by `threadCreateCodeReview`. */ +@Serializable +data class AutopilotThreadBody(val sessionId: String) + /** * Already-decrypted secret files for `projectSecretsWrite`: the phone decrypts * the chosen environment on-device, then relays plaintext over the E2E channel 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 8bb58b75..e5ac3bce 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 @@ -9,6 +9,7 @@ import app.rxlab.rxcode.proto.AutopilotCIFrequencyBody import app.rxlab.rxcode.proto.AutopilotCIHistoryBody import app.rxlab.rxcode.proto.AutopilotCloneRepoBody import app.rxlab.rxcode.proto.AutopilotCloneRepoResult +import app.rxlab.rxcode.proto.AutopilotCodeReviewResult import app.rxlab.rxcode.proto.AutopilotCursorQuery import app.rxlab.rxcode.proto.AutopilotDocsCreateTokenBody import app.rxlab.rxcode.proto.AutopilotDocsDocBody @@ -19,6 +20,7 @@ import app.rxlab.rxcode.proto.AutopilotIDBody import app.rxlab.rxcode.proto.AutopilotOp import app.rxlab.rxcode.proto.AutopilotProjectBody import app.rxlab.rxcode.proto.AutopilotProjectBranchBody +import app.rxlab.rxcode.proto.AutopilotThreadBody import app.rxlab.rxcode.proto.AutopilotProjectSecretsDownloadResult import app.rxlab.rxcode.proto.AutopilotProjectSecretsWriteBody import app.rxlab.rxcode.proto.AutopilotProjectStatus @@ -482,6 +484,32 @@ class AutopilotService( ) ).url + /** + * Ask the Mac to start a `[Code Review]` thread reviewing the whole branch + * (grounded in its briefing). Returns the new thread id so the phone can + * navigate to it once it syncs over. + */ + suspend fun requestProjectCreateCodeReview(projectId: UUID, branch: String): String = + decodeResult( + rawCall( + AutopilotDomain.PROJECT, AutopilotOp.PROJECT_CREATE_CODE_REVIEW, + encodeBody(AutopilotProjectBranchBody(projectId, branch)), + ) + ).threadId + + /** + * Ask the Mac to start a `[Code Review]` thread reviewing a single thread's + * changes (the manual equivalent of the built-in Code Review hook). Returns + * the new review thread's id. + */ + suspend fun requestThreadCreateCodeReview(sessionId: String): String = + decodeResult( + rawCall( + AutopilotDomain.PROJECT, AutopilotOp.THREAD_CREATE_CODE_REVIEW, + encodeBody(AutopilotThreadBody(sessionId)), + ) + ).threadId + /** * Relay already-decrypted secret files for the Mac to write into the project * folder. Decryption happens on-device first (see [SecretsManager]); this only 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 6f0e1c8d..8bbffae1 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 @@ -844,6 +844,14 @@ class MobileAppState @Inject constructor( suspend fun requestProjectCreatePullRequest(projectId: UUID, branch: String): String = autopilot.requestProjectCreatePullRequest(projectId, branch) + /** Start a `[Code Review]` thread on the Mac reviewing the whole branch; returns its id. */ + suspend fun requestProjectCreateCodeReview(projectId: UUID, branch: String): String = + autopilot.requestProjectCreateCodeReview(projectId, branch) + + /** Start a `[Code Review]` thread on the Mac reviewing one thread's changes; returns its id. */ + suspend fun requestThreadCreateCodeReview(sessionId: String): String = + autopilot.requestThreadCreateCodeReview(sessionId) + /** * 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/ProjectActionsMenu.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt index d034c201..d39366a4 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 @@ -11,6 +11,7 @@ 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 @@ -85,12 +86,17 @@ fun ProjectActionsMenu( // 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) + var menuOpen by remember { mutableStateOf(false) } var status 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 info by remember { mutableStateOf(null) } // Only repo-backed projects have autopilot state to load. @@ -121,6 +127,23 @@ fun ProjectActionsMenu( } } + fun createCodeReview() { + if (isCreatingReview || branch == null) return + isCreatingReview = true + 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 + } + } + } + IconButton(onClick = { menuOpen = true }) { Icon(Icons.Outlined.MoreVert, contentDescription = "Project actions") } @@ -180,6 +203,14 @@ fun ProjectActionsMenu( 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") }, @@ -255,6 +286,20 @@ fun ProjectActionsMenu( ) } + 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.") + } + }, + ) + } + info?.let { message -> AlertDialog( onDismissRequest = { info = null }, 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 d528770f..69d14df8 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 @@ -12,20 +12,25 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Archive import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.Delete 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 @@ -95,6 +100,20 @@ fun SessionsScreen( val sessions = remember(state.sessions, projectId) { state.sessions.filter { it.projectId == projectId && !it.isArchived } } + // Split into top-level threads and their nested review children. A review + // child (`parentThreadId` set) nests under its parent when that parent is in + // this list; an orphan child falls back to the top level so it's never hidden. + val sessionIds = remember(sessions) { sessions.map { it.id }.toSet() } + val topLevel = remember(sessions, sessionIds) { + sessions.filter { it.parentThreadId == null || it.parentThreadId !in sessionIds } + } + val childrenByParent = remember(sessions, sessionIds) { + sessions + .filter { it.parentThreadId != null && it.parentThreadId in sessionIds } + .groupBy { it.parentThreadId!! } + .mapValues { (_, group) -> group.sortedBy { it.updatedAt } } + } + var expandedParentIds by remember { mutableStateOf>(emptySet()) } val branchInfo = state.projectBranches[projectId] var newThreadOpen by remember { mutableStateOf(false) } var renameTarget by remember { mutableStateOf(null) } @@ -102,6 +121,18 @@ 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) + } + } + } + Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { @@ -170,21 +201,62 @@ fun SessionsScreen( ), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - items(sessions, key = { it.id }) { session -> - SessionCard( - session = session, - isSelected = session.id == selectedSessionId, - onClick = { - haptics.play(HapticEvent.LightTap) - onSessionClick(session) - }, - onRename = { renameTarget = session }, - onArchive = { - haptics.play(HapticEvent.HeavyImpact) - viewModel.archiveThread(session.id) - }, - onDelete = { deleteTarget = session }, - ) + topLevel.forEach { parent -> + val children = childrenByParent[parent.id].orEmpty() + val expanded = parent.id in expandedParentIds + item(key = parent.id) { + SessionCard( + session = parent, + isSelected = parent.id == selectedSessionId, + onClick = { + haptics.play(HapticEvent.LightTap) + onSessionClick(parent) + }, + onRename = { renameTarget = parent }, + onArchive = { + haptics.play(HapticEvent.HeavyImpact) + viewModel.archiveThread(parent.id) + }, + onDelete = { deleteTarget = parent }, + onCodeReview = { startThreadReview(parent.id) }, + reviewCount = children.size, + isExpanded = expanded, + isReviewing = children.any { it.isStreaming }, + onToggleReviews = if (children.isEmpty()) { + null + } else { + { + expandedParentIds = if (expanded) { + expandedParentIds - parent.id + } else { + expandedParentIds + parent.id + } + } + }, + ) + } + if (expanded) { + itemsIndexed(children, key = { _, child -> child.id }) { index, child -> + SessionCard( + session = child, + isSelected = child.id == selectedSessionId, + onClick = { + haptics.play(HapticEvent.LightTap) + onSessionClick(child) + }, + onRename = { renameTarget = child }, + onArchive = { + haptics.play(HapticEvent.HeavyImpact) + viewModel.archiveThread(child.id) + }, + onDelete = { deleteTarget = child }, + onCodeReview = { startThreadReview(child.id) }, + indentLevel = 1, + titleOverride = "Review ${index + 1}", + showLabel = false, + ) + } + } } } } @@ -287,10 +359,20 @@ private fun SessionCard( onRename: () -> Unit, onArchive: () -> Unit, onDelete: () -> Unit, + onCodeReview: () -> Unit = {}, + reviewCount: Int = 0, + isExpanded: Boolean = false, + isReviewing: Boolean = false, + onToggleReviews: (() -> Unit)? = null, + indentLevel: Int = 0, + titleOverride: String? = null, + showLabel: Boolean = true, ) { var menuOpen by remember { mutableStateOf(false) } ElevatedCard( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(start = (indentLevel * 24).dp), onClick = onClick, colors = CardDefaults.elevatedCardColors( containerColor = if (isSelected) { @@ -305,15 +387,46 @@ private fun SessionCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { + // Disclosure control for a thread that has nested review children; + // tapping it toggles expansion (separate from the card's onClick). + if (onToggleReviews != null) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(1.dp), + modifier = Modifier + .clickable { onToggleReviews() } + .padding(2.dp), + ) { + Icon( + if (isExpanded) Icons.Outlined.KeyboardArrowDown else Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = if (isExpanded) "Hide code reviews" else "Show code reviews", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (isReviewing) { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + ) + } else { + Text( + "$reviewCount", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } StatusDot(session) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - session.title.ifBlank { "Untitled" }, + titleOverride ?: session.title.ifBlank { "Untitled" }, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 2, ) - session.threadLabel?.takeIf { it.isNotBlank() }?.let { label -> + session.threadLabel?.takeIf { showLabel && it.isNotBlank() }?.let { label -> Surface( shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.primaryContainer, @@ -343,6 +456,11 @@ 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("Rename") }, leadingIcon = { Icon(Icons.Outlined.DriveFileRenameOutline, contentDescription = null) }, diff --git a/RxCodeMobile/State/MobileAppState+Autopilot.swift b/RxCodeMobile/State/MobileAppState+Autopilot.swift index 9fce1890..d23c7fc4 100644 --- a/RxCodeMobile/State/MobileAppState+Autopilot.swift +++ b/RxCodeMobile/State/MobileAppState+Autopilot.swift @@ -448,6 +448,26 @@ extension MobileAppState { return url } + /// Asks the Mac to start a `[Code Review]` thread reviewing the whole branch + /// (grounded in its briefing). Returns the new thread id so the phone can + /// navigate to it once it syncs over. + @discardableResult + func requestProjectCreateCodeReview(projectId: UUID, branch: String) async throws -> String { + try await autopilotSend(.project, .projectCreateCodeReview, + body: AutopilotProjectBranchBody(projectId: projectId, branch: branch), + as: AutopilotCodeReviewResult.self).threadId + } + + /// Asks the Mac to start a `[Code Review]` thread reviewing a single thread's + /// changes (the manual equivalent of the built-in Code Review hook). Returns + /// the new review thread's id. + @discardableResult + func requestThreadCreateCodeReview(sessionId: String) async throws -> String { + try await autopilotSend(.project, .threadCreateCodeReview, + body: AutopilotThreadBody(sessionId: sessionId), + as: AutopilotCodeReviewResult.self).threadId + } + /// Downloads the chosen environment into the project folder. The phone /// decrypts on-device with its passkey-derived KEK (the same iCloud-synced /// credential the Mac uses) — running the phone's own passkey ceremony — then diff --git a/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift index 4b9acff9..992e0633 100644 --- a/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift +++ b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift @@ -51,6 +51,10 @@ struct ProjectAutopilotMenuItems: View { var prNumber: Int? = nil var isCreatingPR: Bool = false var onCreatePR: () -> Void = {} + /// Code-review support (mirrors the desktop briefing/project "Code Review" + /// menu). Spawns a `[Code Review]` thread on the Mac reviewing the branch. + var isCreatingReview: Bool = false + var onCodeReview: () -> Void = {} var body: some View { // Mirror the desktop guard: no repo → no autopilot items. @@ -61,6 +65,7 @@ struct ProjectAutopilotMenuItems: View { docsItem(status) releaseItem(status) createPRItem() + codeReviewItem() } else { Button {} label: { Label("Loading Autopilot…", systemImage: "hourglass") @@ -140,6 +145,21 @@ struct ProjectAutopilotMenuItems: View { .disabled(isCreatingPR) } } + + @ViewBuilder + private func codeReviewItem() -> some View { + // Offer a branch-wide code review for any real branch (mirrors the + // desktop briefing/project "Code Review" menu); the Mac spawns a + // `[Code Review]` thread that reviews the branch diff. + if let branch, branch.lowercased() != "unknown" { + Button { + onCodeReview() + } label: { + Label("Code Review for \(branch)", systemImage: "checklist") + } + .disabled(isCreatingReview) + } + } } // MARK: - Host modifier diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index 8c2990bf..fcec8a74 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -13,6 +13,7 @@ struct MobileBriefingDetailView: View { @State private var showingNewThread = false @State private var isInitializingGit = false @State private var isCreatingPR = false + @State private var isCreatingReview = false // Autopilot context menu (1:1 with the desktop briefing/project menu). @State private var autopilotStatus: AutopilotProjectStatus? @@ -66,7 +67,9 @@ struct MobileBriefingDetailView: View { branch: isUnknownBranch ? nil : groupKey.branch, prNumber: ciStatus?.prNumber, isCreatingPR: isCreatingPR, - onCreatePR: { createPullRequest(project: project) } + onCreatePR: { createPullRequest(project: project) }, + isCreatingReview: isCreatingReview, + onCodeReview: { createCodeReview(project: project) } ) if gitHubURL != nil { Divider() } } @@ -120,6 +123,11 @@ struct MobileBriefingDetailView: View { 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." + ) } private var group: GroupedBriefing? { @@ -205,6 +213,27 @@ struct MobileBriefingDetailView: View { } } + /// 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) + } + } + } + // MARK: - Header Card private var headerCard: some View { diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index 6aa0e469..4fd777d5 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -32,6 +32,7 @@ struct SessionsList: View { @State private var showingDeleteProjectConfirm = false @State private var showingSearch = false @State private var isCreatingPR = false + @State private var isCreatingReview = false @Namespace private var glassNamespace // Autopilot actions (1:1 with the desktop project menu), moved here from the @@ -61,6 +62,9 @@ struct SessionsList: View { @State private var displayLimit = SessionsList.pageSize private static let pageSize = 20 + /// Parent thread ids whose nested review children are currently expanded. + @State private var expandedReviewParentIds: Set = [] + var body: some View { glassThreadList .navigationTitle("Threads") @@ -135,6 +139,11 @@ struct SessionsList: View { 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." + ) } /// Ask the Mac to open a PR for the project's current branch, then open it @@ -157,6 +166,40 @@ struct SessionsList: View { } } + /// Ask the Mac to start a `[Code Review]` thread reviewing the project's + /// current branch (grounded in its briefing), then open it once it syncs. + private func createBranchCodeReview(project: Project, branch: String) { + guard !isCreatingReview else { return } + isCreatingReview = true + Task { + defer { isCreatingReview = false } + do { + let threadId = try await state.requestProjectCreateCodeReview( + projectId: project.id, + branch: branch + ) + await state.refreshSnapshot() + selected = threadId + } catch { + autopilotInfo = AutopilotMenuInfo(text: error.localizedDescription, isError: true) + } + } + } + + /// Ask the Mac to start a `[Code Review]` thread reviewing a single thread's + /// changes (the manual equivalent of the built-in Code Review hook). + private func createThreadCodeReview(sessionID: String) { + Task { + do { + let threadId = try await state.requestThreadCreateCodeReview(sessionId: sessionID) + await state.refreshSnapshot() + selected = threadId + } catch { + autopilotInfo = AutopilotMenuInfo(text: error.localizedDescription, isError: true) + } + } + } + // MARK: - Toolbar @ToolbarContentBuilder @@ -180,6 +223,12 @@ struct SessionsList: View { if let branch = currentBranch { createPullRequest(project: project, branch: branch) } + }, + isCreatingReview: isCreatingReview, + onCodeReview: { + if let branch = currentBranch { + createBranchCodeReview(project: project, branch: branch) + } } ) Divider() @@ -217,23 +266,11 @@ struct SessionsList: View { } else { GlassEffectContainer(spacing: 12) { ForEach(visible) { session in - GlassThreadCard( - session: session, - isSelected: selected == session.id, - usesNavigationLink: !usesSelection, - onSelect: usesSelection ? { selected = session.id } : nil - ) - .glassEffectID(session.id, in: glassNamespace) - .onAppear { - if session.id == visible.last?.id { loadMore() } - } - .contextMenu { - threadContextMenu(for: session) - } + threadGroup(for: session) } } - if displayLimit < filtered.count { + if displayLimit < topLevelFiltered.count { loadingIndicator } } @@ -243,6 +280,7 @@ struct SessionsList: View { } .scrollDismissesKeyboard(.interactively) .animation(.spring(duration: 0.3), value: filtered.map(\.id)) + .animation(.easeInOut(duration: 0.2), value: expandedReviewParentIds) .accessibilityIdentifier("thread-list-screen") } @@ -273,6 +311,12 @@ struct SessionsList: View { @ViewBuilder private func threadContextMenu(for session: SessionSummary) -> some View { + Button { + createThreadCodeReview(sessionID: session.id) + } label: { + Label("Code Review", systemImage: "checklist") + } + Button { Task { await state.archiveThread(sessionID: session.id) } } label: { @@ -329,9 +373,113 @@ struct SessionsList: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - /// The slice of `filtered` currently rendered. + /// The slice of top-level threads currently rendered. private var visible: [SessionSummary] { - Array(filtered.prefix(displayLimit)) + Array(topLevelFiltered.prefix(displayLimit)) + } + + /// Top-level threads only — review children (`parentThreadId` pointing at a + /// thread that is also in this list) are nested under their parent. A child + /// whose parent isn't here falls back to the top level so it's never hidden. + private var topLevelFiltered: [SessionSummary] { + let ids = Set(filtered.map(\.id)) + return filtered.filter { session in + guard let parent = session.parentThreadId else { return true } + return !ids.contains(parent) + } + } + + /// Review children grouped by their parent thread id, oldest first so they + /// number naturally as `Review 1`, `Review 2`, … + private var childrenByParent: [String: [SessionSummary]] { + let ids = Set(filtered.map(\.id)) + let children = filtered.filter { session in + guard let parent = session.parentThreadId else { return false } + return ids.contains(parent) + } + return Dictionary(grouping: children, by: { $0.parentThreadId! }) + .mapValues { $0.sorted { $0.updatedAt < $1.updatedAt } } + } + + /// A top-level thread row (with a disclosure control when it has review + /// children) plus, when expanded, its nested review children. + @ViewBuilder + private func threadGroup(for session: SessionSummary) -> some View { + let children = childrenByParent[session.id] ?? [] + let isExpanded = expandedReviewParentIds.contains(session.id) + + if children.isEmpty { + threadCard(for: session) + } else { + HStack(spacing: 6) { + reviewDisclosureButton(for: session, children: children, isExpanded: isExpanded) + threadCard(for: session) + } + } + + if isExpanded { + ForEach(Array(children.enumerated()), id: \.element.id) { index, child in + threadCard(for: child, indentLevel: 1, titleOverride: "Review \(index + 1)") + } + } + } + + private func threadCard( + for session: SessionSummary, + indentLevel: Int = 0, + titleOverride: String? = nil + ) -> some View { + GlassThreadCard( + session: session, + isSelected: selected == session.id, + usesNavigationLink: !usesSelection, + onSelect: usesSelection ? { selected = session.id } : nil, + indentLevel: indentLevel, + titleOverride: titleOverride, + showLabelChip: indentLevel == 0 + ) + .glassEffectID(session.id, in: glassNamespace) + .onAppear { + if indentLevel == 0, session.id == visible.last?.id { loadMore() } + } + .contextMenu { + threadContextMenu(for: session) + } + } + + private func reviewDisclosureButton( + for session: SessionSummary, + children: [SessionSummary], + isExpanded: Bool + ) -> some View { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + if expandedReviewParentIds.contains(session.id) { + expandedReviewParentIds.remove(session.id) + } else { + expandedReviewParentIds.insert(session.id) + } + } + } label: { + VStack(spacing: 2) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 12, weight: .semibold)) + if children.contains(where: { $0.isStreaming }) { + ProgressView() + .controlSize(.mini) + .scaleEffect(0.7) + } else { + Text("\(children.count)") + .font(.system(size: 10, weight: .semibold)) + .monospacedDigit() + } + } + .foregroundStyle(.secondary) + .frame(width: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isExpanded ? "Hide code reviews" : "Show \(children.count) code reviews") } private var usesDesktopSearch: Bool { @@ -347,8 +495,8 @@ struct SessionsList: View { } private func loadMore() { - guard displayLimit < filtered.count else { return } - displayLimit = min(displayLimit + Self.pageSize, filtered.count) + guard displayLimit < topLevelFiltered.count else { return } + displayLimit = min(displayLimit + Self.pageSize, topLevelFiltered.count) } private var filtered: [SessionSummary] { From c2fc129701cf5592a2142b874f050bd377425a86 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:34:14 +0800 Subject: [PATCH 06/14] feat(sidebar): show code-review verdict dot on briefing thread rows Render a small status dot on each briefing thread row reflecting the latest code-review verdict for the session: green (statusSuccess) when passed, red (statusError) when issues were found, none when not reviewed. Co-Authored-By: Claude Opus 4.8 (1M context) --- RxCode/Views/Sidebar/BriefingThreadRow.swift | 11 +++++++++++ RxCode/Views/Sidebar/BriefingView.swift | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/RxCode/Views/Sidebar/BriefingThreadRow.swift b/RxCode/Views/Sidebar/BriefingThreadRow.swift index be821b75..d61acb99 100644 --- a/RxCode/Views/Sidebar/BriefingThreadRow.swift +++ b/RxCode/Views/Sidebar/BriefingThreadRow.swift @@ -8,6 +8,9 @@ struct BriefingThreadRow: View { let item: ThreadSummaryItem let isInProgress: Bool let todoProgress: ChatTodoProgress? + /// Latest code-review verdict for the thread: `true` passed, `false` + /// failed, `nil` not reviewed (no dot shown). + var reviewPassed: Bool? = nil let onSelect: () -> Void var body: some View { @@ -25,6 +28,14 @@ struct BriefingThreadRow: View { .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) + if let reviewPassed { + Circle() + .fill(reviewPassed ? ClaudeTheme.statusSuccess : ClaudeTheme.statusError) + .frame(width: 6, height: 6) + .help(reviewPassed ? "Code review passed" : "Code review found issues") + .accessibilityLabel(reviewPassed ? "Code review passed" : "Code review found issues") + } + if isInProgress { BriefingThreadProgressBadge(progress: todoProgress) } else { diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index 1f53b5b7..86a1e893 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -839,7 +839,8 @@ struct BriefingView: View { BriefingThreadRow( item: item, isInProgress: appState.sessionStates[item.sessionId]?.isStreaming == true, - todoProgress: appState.todoProgress(forSessionId: item.sessionId) + todoProgress: appState.todoProgress(forSessionId: item.sessionId), + reviewPassed: appState.reviewPassedBySession[item.sessionId] ) { appState.selectSession(id: item.sessionId, in: windowState) } From b2b46d1c8762b1eec50dfd519f46b6b9187626aa Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:39:45 +0800 Subject: [PATCH 07/14] feat(code-review): add Code Review action to the mobile chat view Surface the per-thread Code Review action inside the open conversation on mobile, not just the thread list: - iOS: new "Code Review" item in the chat thread-actions menu; spawns a [Code Review] thread for the current thread and deep-links to it once synced. - Android: matching "Code Review" item in the ChatScreen overflow menu; creates the review thread and navigates to it via selectSession. Also refine the macOS thread-row item label to "Code Review for this thread". Co-Authored-By: Claude Opus 4.8 (1M context) --- RxCode/Views/Sidebar/ProjectChatRow.swift | 8 ++++---- .../app/rxlab/rxcode/ui/chat/ChatScreen.kt | 16 +++++++++++++++ .../Views/MobileChatView+Toolbar.swift | 20 +++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/RxCode/Views/Sidebar/ProjectChatRow.swift b/RxCode/Views/Sidebar/ProjectChatRow.swift index 29a2697b..9af05f80 100644 --- a/RxCode/Views/Sidebar/ProjectChatRow.swift +++ b/RxCode/Views/Sidebar/ProjectChatRow.swift @@ -1,5 +1,5 @@ -import SwiftUI import RxCodeCore +import SwiftUI // MARK: - ChatStatus @@ -206,7 +206,7 @@ struct ProjectChatRow: View { } Divider() Button { onCodeReview() } label: { - Label("Code Review", systemImage: "checklist") + Label("Code Review for this thread", systemImage: "checklist") } if !hookMenuItems.isEmpty { Divider() @@ -255,8 +255,8 @@ struct ProjectChatRow: View { } .buttonStyle(.plain) .help(disclosure.isReviewing - ? "Code review in progress" - : (disclosure.isExpanded ? "Hide code reviews" : "Show \(disclosure.count) code review(s)")) + ? "Code review in progress" + : (disclosure.isExpanded ? "Hide code reviews" : "Show \(disclosure.count) code review(s)")) } private static func compactElapsedTime(since date: Date, now: Date = Date()) -> String { 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 7509c62f..d2bdadec 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 @@ -45,6 +45,7 @@ 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 @@ -141,6 +142,7 @@ fun ChatScreen( var showQueuedSheet by remember { mutableStateOf(false) } var openEditPreview by remember { mutableStateOf(null) } var menuExpanded by remember { mutableStateOf(false) } + val menuScope = rememberCoroutineScope() var showRunProfiles by remember { mutableStateOf(false) } var editingProfile by remember { mutableStateOf(null) } var showBrowser by remember { mutableStateOf(false) } @@ -280,6 +282,20 @@ 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("Open in Browser") }, onClick = { diff --git a/RxCodeMobile/Views/MobileChatView+Toolbar.swift b/RxCodeMobile/Views/MobileChatView+Toolbar.swift index 31515f1a..79732b4f 100644 --- a/RxCodeMobile/Views/MobileChatView+Toolbar.swift +++ b/RxCodeMobile/Views/MobileChatView+Toolbar.swift @@ -29,6 +29,11 @@ extension MobileChatView { } label: { Label("View Changes", systemImage: "plus.forwardslash.minus") } + Button { + startCodeReview() + } label: { + Label("Code Review", systemImage: "checklist") + } Divider() Button { showingRenameSheet = true @@ -105,6 +110,21 @@ extension MobileChatView { && state.sessions.contains(where: { $0.id == sessionID }) } + /// 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. + func startCodeReview() { + let sid = sessionID + let projectID = currentProjectID + Task { + guard let threadId = try? await state.requestThreadCreateCodeReview(sessionId: sid) else { return } + await state.refreshSnapshot() + if let projectID { + state.pendingDeepLink = MobileDeepLink(sessionID: threadId, projectID: projectID) + } + } + } + func performArchive() { Task { await state.archiveThread(sessionID: sessionID) } onClose() From 754d939d7b6c2d234a9e3bdd31c4f12bc3b6141e Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:52:21 +0800 Subject: [PATCH 08/14] feat(mobile): redesign iOS collapsed review-thread disclosure Replace the floating chevron+count badge that sat in the left margin and knocked parent cards out of alignment with an attached, full-width disclosure bar beneath the parent ("N Code Reviews" + rotating chevron). All top-level cards now share the same leading edge, and expanded reviews get a connecting vertical rail tying them to their parent. Co-Authored-By: Claude Opus 4.8 (1M context) --- RxCodeMobile/Views/SessionsList.swift | 79 +++++++++++++++++++-------- 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index 4fd777d5..d730ef1c 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -401,25 +401,38 @@ struct SessionsList: View { .mapValues { $0.sorted { $0.updatedAt < $1.updatedAt } } } - /// A top-level thread row (with a disclosure control when it has review - /// children) plus, when expanded, its nested review children. + /// A top-level thread row. When it has review children, an attached + /// disclosure bar hangs beneath it and reveals the nested reviews — every + /// top-level card stays aligned to the same leading edge. @ViewBuilder private func threadGroup(for session: SessionSummary) -> some View { let children = childrenByParent[session.id] ?? [] - let isExpanded = expandedReviewParentIds.contains(session.id) if children.isEmpty { threadCard(for: session) } else { - HStack(spacing: 6) { - reviewDisclosureButton(for: session, children: children, isExpanded: isExpanded) + let isExpanded = expandedReviewParentIds.contains(session.id) + + VStack(spacing: 6) { threadCard(for: session) - } - } + reviewDisclosureBar(for: session, children: children, isExpanded: isExpanded) - if isExpanded { - ForEach(Array(children.enumerated()), id: \.element.id) { index, child in - threadCard(for: child, indentLevel: 1, titleOverride: "Review \(index + 1)") + if isExpanded { + VStack(spacing: 8) { + ForEach(Array(children.enumerated()), id: \.element.id) { index, child in + threadCard(for: child, indentLevel: 1, titleOverride: "Review \(index + 1)") + } + } + .overlay(alignment: .leading) { + // Vertical rail visually tying the reviews to their parent. + Capsule() + .fill(ClaudeTheme.accent.opacity(0.25)) + .frame(width: 2) + .padding(.vertical, 6) + .padding(.leading, 12) + } + .transition(.move(edge: .top).combined(with: .opacity)) + } } } } @@ -447,12 +460,18 @@ struct SessionsList: View { } } - private func reviewDisclosureButton( + /// A slim, full-width control attached beneath a parent thread that owns + /// code reviews. Replaces the old chevron badge that floated in the left + /// margin and knocked every parent card out of alignment. + private func reviewDisclosureBar( for session: SessionSummary, children: [SessionSummary], isExpanded: Bool ) -> some View { - Button { + let isStreaming = children.contains { $0.isStreaming } + let countLabel = "\(children.count) \(children.count == 1 ? "Code Review" : "Code Reviews")" + + return Button { withAnimation(.easeInOut(duration: 0.2)) { if expandedReviewParentIds.contains(session.id) { expandedReviewParentIds.remove(session.id) @@ -461,24 +480,38 @@ struct SessionsList: View { } } } label: { - VStack(spacing: 2) { - Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + HStack(spacing: 8) { + Image(systemName: "checklist") .font(.system(size: 12, weight: .semibold)) - if children.contains(where: { $0.isStreaming }) { + + Text(countLabel) + .font(.system(size: 13, weight: .semibold)) + + if isStreaming { ProgressView() .controlSize(.mini) - .scaleEffect(0.7) - } else { - Text("\(children.count)") - .font(.system(size: 10, weight: .semibold)) - .monospacedDigit() + .scaleEffect(0.8) } + + Spacer(minLength: 0) + + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .bold)) + .rotationEffect(.degrees(isExpanded ? 0 : -90)) + } + .foregroundStyle(ClaudeTheme.accent) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(ClaudeTheme.accent.opacity(0.08)) } - .foregroundStyle(.secondary) - .frame(width: 24) - .contentShape(Rectangle()) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) + // Indented under the parent's content to read as its child. + .padding(.leading, 28) .accessibilityLabel(isExpanded ? "Hide code reviews" : "Show \(children.count) code reviews") } From 24b0bcd70d73ef03eae7d947f6bc9ed72b3b9bc1 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:52:35 +0800 Subject: [PATCH 09/14] feat(briefing): exclude code-review threads from briefings Code-review threads were appearing as briefing threads on macOS, iOS, and Android. Exclude them at the root so all surfaces stay clean: - Skip writing thread/title summaries for [Code Review] threads in storeThreadSummaryTitle and updateStoredThreadSummary, so review threads never enter the briefing dataset or branch-briefing text. - Add ThreadStore.codeReviewThreadIds(label:) to identify review threads by their persisted threadLabel, filtering in memory to avoid SwiftData optional #Predicate pitfalls. - Filter already-persisted review summaries out of BriefingView and the mobile snapshot (mobileThreadSummaries), which feeds iOS/Android. Co-Authored-By: Claude Opus 4.8 (1M context) --- RxCode/App/AppState+MobileSnapshots.swift | 4 +- RxCode/App/AppState+SessionLifecycle.swift | 9 +++++ RxCode/Services/ThreadStore.swift | 43 ++++++++++++++++++++-- RxCode/Views/Sidebar/BriefingView.swift | 19 +++++++--- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/RxCode/App/AppState+MobileSnapshots.swift b/RxCode/App/AppState+MobileSnapshots.swift index 0bf85164..f358879b 100644 --- a/RxCode/App/AppState+MobileSnapshots.swift +++ b/RxCode/App/AppState+MobileSnapshots.swift @@ -442,8 +442,10 @@ extension AppState { func mobileThreadSummaries() -> [MobileThreadSummary] { let knownProjectIds = Set(projects.map(\.id)) + // Exclude `[Code Review]` threads — they aren't briefing threads. + let reviewIds = threadStore.codeReviewThreadIds(label: Self.manualCodeReviewLabel) return threadStore.allThreadSummaryItems() - .filter { knownProjectIds.contains($0.projectId) } + .filter { knownProjectIds.contains($0.projectId) && !reviewIds.contains($0.sessionId) } .map { MobileThreadSummary( sessionId: $0.sessionId, diff --git a/RxCode/App/AppState+SessionLifecycle.swift b/RxCode/App/AppState+SessionLifecycle.swift index ae7b1004..2ccc8864 100644 --- a/RxCode/App/AppState+SessionLifecycle.swift +++ b/RxCode/App/AppState+SessionLifecycle.swift @@ -119,6 +119,10 @@ extension AppState { } func storeThreadSummaryTitle(_ summary: ChatSession.Summary, title: String) async { + // `[Code Review]` threads are excluded from briefings — reviewing a + // review isn't meaningful and they shouldn't appear as briefing threads. + guard summary.threadLabel != Self.manualCodeReviewLabel else { return } + let projectPath = projects.first(where: { $0.id == summary.projectId })?.path let branchPath = summary.worktreePath ?? projectPath let currentBranch: String? @@ -213,6 +217,11 @@ extension AppState { finalResponse: String, summary: ChatSession.Summary ) async { + // `[Code Review]` threads are excluded from briefings (and from the + // branch briefing aggregation below) — they review the work, they aren't + // part of the branch's story. + guard summary.threadLabel != Self.manualCodeReviewLabel else { return } + let previousSummary = threadStore.threadSummaryItem(sessionId: sessionId)?.summary guard let threadSummary = await generateThreadSummary( previousSummary: previousSummary, diff --git a/RxCode/Services/ThreadStore.swift b/RxCode/Services/ThreadStore.swift index ac2ad9e6..1336d9d7 100644 --- a/RxCode/Services/ThreadStore.swift +++ b/RxCode/Services/ThreadStore.swift @@ -33,7 +33,11 @@ final class ThreadStore { let config = ModelConfiguration(schema: schema, url: url) do { let container = try ModelContainer(for: schema, configurations: [config]) - return ThreadStore(context: ModelContext(container)) + let store = ThreadStore(context: ModelContext(container)) + // Sweep hook cards left mid-run by a previous launch so they don't + // rebuild as a perpetual spinner. + store.finalizeInterruptedHooks() + return store } catch { // Fall back to an in-memory container so the app still launches. let fallback = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) @@ -103,6 +107,17 @@ final class ThreadStore { return ((try? context.fetch(descriptor)) ?? []).map { $0.toItem() } } + /// Session ids of `[Code Review]` threads (manual or hook-spawned), + /// identified by their thread label. Used to keep review threads out of + /// briefings even for summaries persisted before review threads were + /// excluded at write time. Filters in memory rather than via `#Predicate` + /// to avoid SwiftData's optional-vs-non-optional comparison pitfalls on the + /// optional `threadLabel`. + func codeReviewThreadIds(label: String) -> Set { + let rows = (try? context.fetch(FetchDescriptor())) ?? [] + return Set(rows.filter { $0.threadLabel == label }.map { $0.id }) + } + func branchBriefingItem(projectId: UUID, branch: String) -> BranchBriefingItem? { fetchBranchBriefing(projectId: projectId, branch: branch)?.toItem() } @@ -565,7 +580,8 @@ final class ThreadStore { name: String, trigger: String, output: String, - isError: Bool + isError: Bool, + isComplete: Bool = true ) { if let existing = fetchHookStatus(sessionId: sessionId) { existing.toolId = toolId @@ -573,6 +589,7 @@ final class ThreadStore { existing.trigger = trigger existing.output = output existing.isError = isError + existing.isComplete = isComplete existing.updatedAt = .now } else { context.insert(HookStatusRecord( @@ -581,12 +598,32 @@ final class ThreadStore { name: name, trigger: trigger, output: output, - isError: isError + isError: isError, + isComplete: isComplete )) } save() } + /// Finalize any hook rows left "in progress" by a previous launch (the app + /// closed while a long-running hook like code review was still streaming). + /// Without this they would rebuild as a spinner that never resolves. + func finalizeInterruptedHooks() { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.isComplete == false } + ) + guard let rows = try? context.fetch(descriptor), !rows.isEmpty else { return } + for row in rows { + row.isComplete = true + row.isError = true + if row.output.isEmpty { + row.output = "Interrupted — the app closed while this hook was running." + } + row.updatedAt = .now + } + save() + } + /// Stamp linkage metadata (parent thread / label / skip-hooks) onto a thread /// row. Used right after a linked `[Code Review]` thread's real id resolves. func setThreadLinkage( diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index 86a1e893..e6639869 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -52,6 +52,16 @@ struct BriefingView: View { Set(appState.projects.map(\.id)) } + /// Thread summaries for known projects, excluding `[Code Review]` threads. + /// Review threads are kept out of briefings at write time; this also filters + /// any summaries persisted before that exclusion existed. + private func visibleThreadSummaryItems() -> [ThreadSummaryItem] { + let knownIds = knownProjectIds + let reviewIds = appState.threadStore.codeReviewThreadIds(label: AppState.manualCodeReviewLabel) + return appState.threadStore.allThreadSummaryItems() + .filter { knownIds.contains($0.projectId) && !reviewIds.contains($0.sessionId) } + } + private var groups: [BriefingGroup] { _ = appState.branchBriefingRevision _ = appState.threadSummaryRevision @@ -59,8 +69,7 @@ struct BriefingView: View { let knownIds = knownProjectIds let briefings = appState.threadStore.allBranchBriefingItems() .filter { knownIds.contains($0.projectId) } - let summaries = appState.threadStore.allThreadSummaryItems() - .filter { knownIds.contains($0.projectId) } + let summaries = visibleThreadSummaryItems() struct Bucket { var projectId: UUID @@ -127,9 +136,7 @@ struct BriefingView: View { appState.threadStore.allBranchBriefingItems() .filter { knownIds.contains($0.projectId) } .map(\.projectId) - + appState.threadStore.allThreadSummaryItems() - .filter { knownIds.contains($0.projectId) } - .map(\.projectId) + + visibleThreadSummaryItems().map(\.projectId) ) return appState.projects.filter { ids.contains($0.id) } } @@ -171,7 +178,7 @@ struct BriefingView: View { _ = appState.threadSummaryRevision let knownIds = knownProjectIds return appState.threadStore.allBranchBriefingItems().contains { knownIds.contains($0.projectId) } - || appState.threadStore.allThreadSummaryItems().contains { knownIds.contains($0.projectId) } + || !visibleThreadSummaryItems().isEmpty } private var projectPathsKey: String { From ffc437d2cdd128961efb897a3d545ff59391fa91 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:54:45 +0800 Subject: [PATCH 10/14] fix(code-review): don't auto-retry a cancelled review The Code Review hook re-prompted the original thread on both an explicit reviewer FAIL and an "unknown" verdict (a reply with no REVIEW_RESULT: marker). A manually cancelled review thread produces exactly that markerless reply, so it was mistaken for "reviewer wants changes" and kicked off an unwanted auto-retry fix turn. Split the cases: only an explicit FAIL re-prompts (bounded by maxReviewRounds). An unknown verdict now records not-passed and finishes the card without re-prompting, so a cancelled or interrupted review never auto-retries. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Services/Hooks/hooks/CodeReviewHook.swift | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/RxCode/Services/Hooks/hooks/CodeReviewHook.swift b/RxCode/Services/Hooks/hooks/CodeReviewHook.swift index b8398286..340d3c77 100644 --- a/RxCode/Services/Hooks/hooks/CodeReviewHook.swift +++ b/RxCode/Services/Hooks/hooks/CodeReviewHook.swift @@ -8,9 +8,12 @@ import RxCodeCore /// 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 (or no verdict) → 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. +/// - 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. +/// - 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. /// /// Runs on `.afterSessionStop` (after the thread is finalized/saved). Registered /// last so its (possibly long) work doesn't delay the response notification. @@ -43,6 +46,11 @@ final class CodeReviewHook: Hook { .filter { $0.action == .codeReview } guard let hook = hooks.first else { return .ignored } + // Defer while the user still has queued messages — they'll run as further + // turns, so don't review a half-finished change. The next stop (queue + // drained) triggers the review. + if payload.hasQueuedFollowups { return .ignored } + let changedFiles = controller.changedFilePaths(sessionId: payload.sessionId) guard !changedFiles.isEmpty else { // Nothing changed — treat as passed so a paired commit hook can no-op @@ -70,6 +78,17 @@ final class CodeReviewHook: Hook { "summary": .string("Code review · \(changedFiles.count) changed file(s)"), ] ) + // Persist the card in-progress so it survives a reload while the review + // (which can take minutes) is still running. `finishCard` updates it. + controller.persistHookStatus( + sessionKey: payload.sessionKey, + toolId: card.toolId, + name: hook.name, + trigger: hook.trigger.displayName, + output: "", + isError: false, + isComplete: false + ) logger.debug("[Hook] spawning code-review thread for session \(payload.sessionId, privacy: .public) files=\(changedFiles.count)") let result = await controller.spawnLinkedThread( @@ -108,7 +127,7 @@ final class CodeReviewHook: Hook { controller.setReviewRound(0, sessionId: payload.sessionId) return .proceed - case .fail(let notes), .unknown(let notes): + case .fail(let notes): recordVerdict(false, payload: payload, controller: controller) let round = controller.reviewRound(sessionId: payload.sessionId) if round + 1 >= Self.maxReviewRounds { @@ -135,6 +154,21 @@ final class CodeReviewHook: Hook { """ ) 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. + 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)", + isError: true) + return .ignored } } @@ -155,7 +189,8 @@ final class CodeReviewHook: Hook { name: hook.name, trigger: hook.trigger.displayName, output: result, - isError: isError + isError: isError, + isComplete: true ) } From 85f285963a52dd0903f103bbce443733f3df8d41 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:05:23 +0800 Subject: [PATCH 11/14] fix(summary): skip thread summary/memory for hook-injected commit turn - the Commit & Push hook injects a synthetic commit prompt as a user message, which leaked into thread summaries and extracted memories - capture the commitPush setup-session marker before the after-stop dispatch consumes it, and skip summary/memory updates for that turn Co-Authored-By: Claude Opus 4.8 (1M context) --- .../RxCodeCore/Hooks/HookController.swift | 6 +- .../RxCodeCore/Hooks/HookPayloads.swift | 9 +- .../RxCodeCore/Models/HookStatusRecord.swift | 11 ++ RxCode/App/AppState+CodeReview.swift | 10 ++ RxCode/App/AppState+CrossProject.swift | 51 ++++-- RxCode/App/AppState+Hooks.swift | 8 +- RxCode/App/AppState+MobileSnapshots.swift | 2 +- RxCode/Resources/Localizable.xcstrings | 72 +++++++- .../Hooks/AppStateHookController.swift | 21 ++- .../Services/Hooks/hooks/CommitPushHook.swift | 5 + .../Services/Hooks/hooks/UserAddedHook.swift | 3 +- RxCode/Services/ThreadStore+Embeddings.swift | 52 ++++++ RxCode/Services/ThreadStore+Memories.swift | 106 ++++++++++++ RxCode/Services/ThreadStore.swift | 161 +----------------- RxCode/Views/MainView.swift | 9 +- RxCode/Views/SettingsView.swift | 45 ++++- RxCode/Views/Sidebar/BriefingView.swift | 2 +- RxCode/Views/Sidebar/ProjectChatRow.swift | 18 ++ RxCode/Views/Sidebar/ProjectTreeView.swift | 3 +- RxCodeMobile/Resources/Localizable.xcstrings | 27 +++ RxCodeMobile/Views/GlassThreadCard.swift | 113 +++++++++++- RxCodeMobile/Views/SessionsList.swift | 102 +++-------- 22 files changed, 556 insertions(+), 280 deletions(-) create mode 100644 RxCode/Services/ThreadStore+Embeddings.swift create mode 100644 RxCode/Services/ThreadStore+Memories.swift diff --git a/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift index adfe5ff8..78a68917 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookController.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookController.swift @@ -22,8 +22,10 @@ public protocol HookController: AnyObject { func completeCard(_ handle: HookCardHandle, sessionKey: String, result: String, isError: Bool) /// Persist a session's "last hook" so the synthetic card can be rebuilt on - /// reload (hook cards never reach the CLI transcript). - func persistHookStatus(sessionKey: String, toolId: String, name: String, trigger: String, output: String, isError: Bool) + /// reload (hook cards never reach the CLI transcript). Pass `isComplete: + /// false` at insert time for a long-running hook so an in-progress card + /// survives a reload; call again with `isComplete: true` on completion. + func persistHookStatus(sessionKey: String, toolId: String, name: String, trigger: String, output: String, isError: Bool, isComplete: Bool) /// Enabled user hook profiles for a project + trigger, loading from disk on /// first access. diff --git a/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift index 8f2bbd0f..b73294ab 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift @@ -91,6 +91,11 @@ public struct SessionEndPayload: Codable, Sendable { /// The most recent assistant text, captured at dispatch time for the /// response-complete notification body fallback. public let lastAssistantText: String + /// True when the thread still had user messages queued at the moment it + /// stopped — captured synchronously before the auto-flush pops one. Stop + /// hooks that act on the change (code review, commit & push) defer while + /// this is true so they only run once the queue has fully drained. + public let hasQueuedFollowups: Bool public init( project: Project, @@ -98,7 +103,8 @@ public struct SessionEndPayload: Codable, Sendable { sessionId: String, reason: SessionEndReason, turnDidError: Bool, - lastAssistantText: String + lastAssistantText: String, + hasQueuedFollowups: Bool = false ) { self.project = project self.sessionKey = sessionKey @@ -106,6 +112,7 @@ public struct SessionEndPayload: Codable, Sendable { self.reason = reason self.turnDidError = turnDidError self.lastAssistantText = lastAssistantText + self.hasQueuedFollowups = hasQueuedFollowups } } diff --git a/Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift b/Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift index d1c4dd04..24fbab7c 100644 --- a/Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift +++ b/Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift @@ -7,6 +7,10 @@ import SwiftData /// CLI-backed session reloads. Hook cards are synthetic `ChatMessage`s injected /// by `runHooks`; they never reach the CLI's jsonl transcript, so without this /// sidecar they vanish when messages are reloaded from disk. +/// +/// The row is written at *insert* time (in-progress) for long-running hooks +/// like code review, so the card survives a reload that happens mid-run, and +/// updated again on completion. @Model public final class HookStatusRecord { @Attribute(.unique) public var sessionId: String @@ -18,6 +22,11 @@ public final class HookStatusRecord { public var trigger: String public var output: String public var isError: Bool + /// False while the hook card is still running (spinner). Defaulted `true` + /// so existing rows migrate as already-finished. An in-progress row is + /// rebuilt as a running card and finalized on completion — or, if the app + /// closed mid-hook, swept to an "interrupted" state on next launch. + public var isComplete: Bool = true public var updatedAt: Date public init( @@ -27,6 +36,7 @@ public final class HookStatusRecord { trigger: String, output: String, isError: Bool, + isComplete: Bool = true, updatedAt: Date = .now ) { self.sessionId = sessionId @@ -35,6 +45,7 @@ public final class HookStatusRecord { self.trigger = trigger self.output = output self.isError = isError + self.isComplete = isComplete self.updatedAt = updatedAt } } diff --git a/RxCode/App/AppState+CodeReview.swift b/RxCode/App/AppState+CodeReview.swift index 503c0b89..69861b2c 100644 --- a/RxCode/App/AppState+CodeReview.swift +++ b/RxCode/App/AppState+CodeReview.swift @@ -27,6 +27,16 @@ extension AppState { /// the sidebar review UI). static let manualCodeReviewLabel = "Code Review" + /// Session ids of `[Code Review]` threads (manual or hook-spawned), + /// identified by their thread label. Used to keep review threads out of + /// briefings — both desktop and the mobile snapshot — even for summaries + /// persisted before review threads were excluded at write time. + var codeReviewThreadIds: Set { + Set(allSessionSummaries + .filter { $0.threadLabel == Self.manualCodeReviewLabel } + .map(\.id)) + } + // MARK: - Branch-level review /// Start a `[Code Review]` thread that reviews *all* the changes on `branch`, diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index 9f82b511..8f5c591b 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -698,6 +698,11 @@ extension AppState { let markUnread = !isFg && !resultEvent.isError let stopProject = projects.first(where: { $0.id == projectId }) + // Capture queued-followup state now, synchronously, before + // `finalizeStreamSession` schedules the auto-flush that pops + // the next queued message. Stop hooks (review/commit) use this + // to defer until the queue has fully drained. + let hasQueuedFollowups = !threadStore.loadQueue(sessionKey: sessionKey).isEmpty finalizeStreamSession(for: sessionKey) { state in if let cost = resultEvent.totalCostUsd { state.costUsd = cost } @@ -726,7 +731,8 @@ extension AppState { sessionId: resultEvent.sessionId, reason: .completed, turnDidError: resultEvent.isError, - lastAssistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages) + lastAssistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages), + hasQueuedFollowups: hasQueuedFollowups )) if stopResult.hasError { stopHookFailureOutput = stopResult.combinedOutput @@ -762,6 +768,18 @@ extension AppState { reconcileFromDisk(sessionId: resultEvent.sessionId, projectId: projectId, cwd: cwd) } + // Whether this turn was the synthetic commit/push follow-up the + // Commit & Push hook injected. Captured BEFORE the after-stop + // dispatch below, which is where CommitPushHook consumes the + // marker. A hook-injected turn's last "user" message is the + // commit prompt, not the user's words — summarizing or + // extracting memories from it pollutes the briefing/memories + // with "Commit the changes from this session…" boilerplate. + let wasHookInjectedTurn = isSetupSession( + kind: HookSetupKind.commitPush, + sessionKey: sessionKey + ) + // After-session-stop hooks: shown only, not re-saved. This // dispatch also drives the response-complete notification // (ResponseNotificationHook), which self-suppresses unless @@ -773,7 +791,8 @@ extension AppState { sessionId: resultEvent.sessionId, reason: .completed, turnDidError: resultEvent.isError, - lastAssistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages) + lastAssistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages), + hasQueuedFollowups: hasQueuedFollowups )) } @@ -801,17 +820,23 @@ extension AppState { // ResponseNotificationHook via the after-session-end // dispatch above. - scheduleThreadSummaryUpdate( - sessionId: resultEvent.sessionId, - projectId: projectId, - cwd: cwd, - messages: stateForSession(sessionKey).messages - ) - scheduleMemoryExtraction( - sessionId: resultEvent.sessionId, - projectId: projectId, - messages: stateForSession(sessionKey).messages - ) + // Skip summary/memory updates for the hook-injected + // commit & push turn — its last user message is the + // commit prompt, not the user's, and would otherwise + // leak into the thread summary and extracted memories. + if !wasHookInjectedTurn { + scheduleThreadSummaryUpdate( + sessionId: resultEvent.sessionId, + projectId: projectId, + cwd: cwd, + messages: stateForSession(sessionKey).messages + ) + scheduleMemoryExtraction( + sessionId: resultEvent.sessionId, + projectId: projectId, + messages: stateForSession(sessionKey).messages + ) + } // If this session is running in the background, automatically process any queued messages. // Foreground sessions are handled by InputBarView via isStreaming onChange. diff --git a/RxCode/App/AppState+Hooks.swift b/RxCode/App/AppState+Hooks.swift index c3424de9..e3c3c838 100644 --- a/RxCode/App/AppState+Hooks.swift +++ b/RxCode/App/AppState+Hooks.swift @@ -78,6 +78,10 @@ extension AppState { } guard !alreadyPresent else { return messages } + // A still-running record (e.g. a code review in flight) rebuilds as a + // spinner: `result == nil` drives the "running" hook card in + // `ToolResultView`. It's finalized live by `completeCard` (matched on + // tool id) or swept to "interrupted" on the next launch. let toolCall = ToolCall( id: record.toolId, name: Self.hookToolName(for: record.name), @@ -85,7 +89,7 @@ extension AppState { "name": .string(record.name), "trigger": .string(record.trigger), ], - result: record.output, + result: record.isComplete ? record.output : nil, isError: record.isError ) var result = messages @@ -93,7 +97,7 @@ extension AppState { id: UUID(), role: .assistant, blocks: [.toolCall(toolCall)], - isResponseComplete: true, + isResponseComplete: record.isComplete, timestamp: record.updatedAt )) return result diff --git a/RxCode/App/AppState+MobileSnapshots.swift b/RxCode/App/AppState+MobileSnapshots.swift index f358879b..eea3a997 100644 --- a/RxCode/App/AppState+MobileSnapshots.swift +++ b/RxCode/App/AppState+MobileSnapshots.swift @@ -443,7 +443,7 @@ extension AppState { func mobileThreadSummaries() -> [MobileThreadSummary] { let knownProjectIds = Set(projects.map(\.id)) // Exclude `[Code Review]` threads — they aren't briefing threads. - let reviewIds = threadStore.codeReviewThreadIds(label: Self.manualCodeReviewLabel) + let reviewIds = codeReviewThreadIds return threadStore.allThreadSummaryItems() .filter { knownProjectIds.contains($0.projectId) && !reviewIds.contains($0.sessionId) } .map { diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 1fbb5875..a267288c 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -1035,6 +1035,9 @@ } } } + }, + "Add a Code Review hook and a second agent reviews every change before you ship it." : { + }, "Add a Git repository by URL" : { "extractionState" : "stale", @@ -1599,6 +1602,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)." : { + }, "Agent Availability" : { "localizations" : { @@ -2391,6 +2397,9 @@ } } } + }, + "Automatic code review" : { + }, "Automation" : { "localizations" : { @@ -3274,6 +3283,24 @@ }, "Code Review" : { + }, + "Code Review for %@" : { + + }, + "Code Review for Current Branch" : { + + }, + "Code Review for this thread" : { + + }, + "Code review found issues" : { + + }, + "Code review in progress" : { + + }, + "Code review passed" : { + }, "Collapse chats" : { "localizations" : { @@ -3379,6 +3406,9 @@ } } } + }, + "Commit when a session finishes" : { + }, "Configuration" : { "localizations" : { @@ -5900,6 +5930,9 @@ } } } + }, + "Failed reviews are sent back to the original thread so the agent can fix and get re-reviewed." : { + }, "Failed to check status" : { "localizations" : { @@ -6474,6 +6507,9 @@ } } } + }, + "Got it" : { + }, "Headers" : { "localizations" : { @@ -6490,6 +6526,9 @@ } } } + }, + "Hide code reviews" : { + }, "Hide details" : { "localizations" : { @@ -8565,6 +8604,9 @@ } } } + }, + "New in RxCode" : { + }, "New Template" : { "localizations" : { @@ -9726,9 +9768,6 @@ } } } - }, - "On a clean session stop, the change is sent to a linked [Code Review] thread that runs no hooks. If the review requests changes, its notes are fed back to the agent to keep fixing (up to 3 times)." : { - }, "On failure the hook output is sent back to the agent, which keeps fixing until the hook passes (max 3 retries)." : { "localizations" : { @@ -10509,6 +10548,9 @@ } } } + }, + "Pick which model performs the review — defaults to the same model as the thread." : { + }, "Pin" : { "localizations" : { @@ -10908,6 +10950,9 @@ } } } + }, + "Push to the remote automatically, or keep the commit local." : { + }, "Quit" : { "localizations" : { @@ -12044,6 +12089,9 @@ } } } + }, + "Runs after a session finishes and reviews the modified files in a linked thread." : { + }, "Runs after streaming stops. Its output is shown only — nothing is passed back to the session." : { "extractionState" : "stale", @@ -12751,6 +12799,9 @@ } } } + }, + "See the latest features and updates" : { + }, "Select a dispatchable release workflow first (re-add the repo to rescan if needed)." : { "localizations" : { @@ -13306,6 +13357,9 @@ } } } + }, + "Show %lld code review(s)" : { + }, "Show active chats" : { "localizations" : { @@ -14281,6 +14335,9 @@ } } } + }, + "The new Commit & Push hook commits — and optionally pushes — your work the moment an agent session completes." : { + }, "The next version is computed by semantic-release from the commit history." : { "localizations" : { @@ -14716,6 +14773,9 @@ } } } + }, + "Triggers automatically once an agent session finishes." : { + }, "Type" : { "localizations" : { @@ -15336,6 +15396,12 @@ } } } + }, + "What's New" : { + + }, + "When a Code Review hook is configured, commits only happen after the review passes." : { + }, "When CI fails on a project's current branch, automatically start a thread so an agent can fix it. CI failures are always notified; this only controls the automatic fix." : { "localizations" : { diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index 5149cb60..f87bf783 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -41,21 +41,28 @@ final class AppStateHookController: HookController { func completeCard(_ handle: HookCardHandle, sessionKey: String, result: String, isError: Bool) { app?.updateState(sessionKey) { state in - guard let idx = state.messages.firstIndex(where: { $0.id == handle.messageId }) else { return } + // Match by message id OR tool id: after a mid-run reload the card may + // have been rebuilt from the persisted record with a fresh message id + // but the same tool id, and we still want completion to land live. + guard let idx = state.messages.firstIndex(where: { message in + message.id == handle.messageId + || message.blocks.contains { $0.toolCall?.id == handle.toolId } + }) else { return } state.messages[idx].setToolResult(id: handle.toolId, result: result, isError: isError) state.messages[idx].isStreaming = false state.messages[idx].isResponseComplete = true } } - func persistHookStatus(sessionKey: String, toolId: String, name: String, trigger: String, output: String, isError: Bool) { + func persistHookStatus(sessionKey: String, toolId: String, name: String, trigger: String, output: String, isError: Bool, isComplete: Bool) { app?.threadStore.setHookStatus( sessionId: sessionKey, toolId: toolId, name: name, trigger: trigger, output: output, - isError: isError + isError: isError, + isComplete: isComplete ) } @@ -157,9 +164,11 @@ final class AppStateHookController: HookController { threadId: nil, prompt: prompt, model: model, - // Plan mode keeps the reviewer read-only (no edits) and runs - // unattended without permission prompts. - permissionMode: .plan, + // Auto mode lets the reviewer run unattended, bypassing almost + // all permission prompts so it can freely inspect the repo (read + // files, grep, run checks). The prompt still instructs it not to + // edit; it just isn't gated on per-tool approvals like plan mode. + permissionMode: .auto, waitForResponse: true, timeoutSeconds: timeoutSeconds, parentThreadId: parentThreadId, diff --git a/RxCode/Services/Hooks/hooks/CommitPushHook.swift b/RxCode/Services/Hooks/hooks/CommitPushHook.swift index bbd8c8f0..9282e971 100644 --- a/RxCode/Services/Hooks/hooks/CommitPushHook.swift +++ b/RxCode/Services/Hooks/hooks/CommitPushHook.swift @@ -36,6 +36,11 @@ final class CommitPushHook: Hook { guard payload.reason == .completed, !payload.turnDidError else { return .ignored } + // Defer while the user still has queued messages — they'll run as further + // turns, so don't commit a half-finished change. The next stop (queue + // drained) triggers the commit. + if payload.hasQueuedFollowups { return .ignored } + // Review gate: when a Code Review hook is also configured, only commit if // the latest review passed. let reviewConfigured = await controller.enabledHookProfiles(projectId: payload.project.id, trigger: .afterSessionStop) diff --git a/RxCode/Services/Hooks/hooks/UserAddedHook.swift b/RxCode/Services/Hooks/hooks/UserAddedHook.swift index 0b285cdd..f9c41416 100644 --- a/RxCode/Services/Hooks/hooks/UserAddedHook.swift +++ b/RxCode/Services/Hooks/hooks/UserAddedHook.swift @@ -66,7 +66,8 @@ final class UserAddedHook: Hook { name: hook.name, trigger: hook.trigger.displayName, output: displayOutput, - isError: result.isError + isError: result.isError, + isComplete: true ) if result.isError { anyError = true } diff --git a/RxCode/Services/ThreadStore+Embeddings.swift b/RxCode/Services/ThreadStore+Embeddings.swift new file mode 100644 index 00000000..a01f2179 --- /dev/null +++ b/RxCode/Services/ThreadStore+Embeddings.swift @@ -0,0 +1,52 @@ +import Foundation +import SwiftData +import RxCodeCore + +// MARK: - Thread Embedding Chunks + +@MainActor +extension ThreadStore { + func loadAllEmbeddingChunks() -> [ThreadEmbeddingChunk] { + let descriptor = FetchDescriptor() + return (try? context.fetch(descriptor)) ?? [] + } + + func loadEmbeddingChunks(threadId: String) -> [ThreadEmbeddingChunk] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.threadId == threadId }, + sortBy: [SortDescriptor(\.chunkIndex, order: .forward)] + ) + return (try? context.fetch(descriptor)) ?? [] + } + + /// Replace all chunks for a thread atomically. Old rows are deleted first + /// so re-indexing cannot leave orphans behind. + func replaceEmbeddingChunks(threadId: String, chunks: [ThreadEmbeddingChunk]) { + deleteEmbeddingChunkRows(threadId: threadId) + for chunk in chunks { + context.insert(chunk) + } + save() + } + + func deleteEmbeddingChunks(threadId: String) { + deleteEmbeddingChunkRows(threadId: threadId) + save() + } + + /// Wipe every persisted embedding chunk across all threads. + func deleteAllEmbeddingChunks() { + let descriptor = FetchDescriptor() + let rows = (try? context.fetch(descriptor)) ?? [] + for row in rows { context.delete(row) } + save() + } + + func deleteEmbeddingChunkRows(threadId: String) { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.threadId == threadId } + ) + let rows = (try? context.fetch(descriptor)) ?? [] + for row in rows { context.delete(row) } + } +} diff --git a/RxCode/Services/ThreadStore+Memories.swift b/RxCode/Services/ThreadStore+Memories.swift new file mode 100644 index 00000000..cf0da678 --- /dev/null +++ b/RxCode/Services/ThreadStore+Memories.swift @@ -0,0 +1,106 @@ +import Foundation +import SwiftData +import RxCodeCore + +/// Memory-record CRUD for `ThreadStore`. Split out of `ThreadStore.swift` to +/// keep that file under the file-length limit; the persistence model and main +/// actor scoping are unchanged. +extension ThreadStore { + // MARK: - Memories + + func loadAllMemories() -> [MemoryRecord] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] + ) + return (try? context.fetch(descriptor)) ?? [] + } + + func loadAllMemorySnapshots() -> [MemoryVectorSnapshot] { + loadAllMemories().map { $0.toVectorSnapshot() } + } + + func fetchMemory(id: String) -> MemoryRecord? { + var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + descriptor.fetchLimit = 1 + return (try? context.fetch(descriptor))?.first + } + + func upsertMemory( + id: String?, + content: String, + projectId: UUID?, + sessionId: String?, + sourceMessageId: UUID?, + kind: String, + scope: String, + vector: Data, + dim: Int + ) -> MemoryItem { + let memoryId = id ?? UUID().uuidString + let now = Date() + if let existing = fetchMemory(id: memoryId) { + existing.apply( + content: content, + projectId: projectId, + sessionId: sessionId, + sourceMessageId: sourceMessageId, + kind: kind, + scope: scope, + vector: vector, + dim: dim, + updatedAt: now + ) + save() + return existing.toItem() + } else { + let row = MemoryRecord( + id: memoryId, + content: content, + projectId: projectId, + sessionId: sessionId, + sourceMessageId: sourceMessageId, + createdAt: now, + updatedAt: now, + kind: kind, + scope: scope, + vector: vector, + dim: dim + ) + context.insert(row) + save() + return row.toItem() + } + } + + func touchMemories(ids: [String], at date: Date = .now) { + guard !ids.isEmpty else { return } + for id in ids { + fetchMemory(id: id)?.touch(at: date) + } + save() + } + + func deleteMemory(id: String) { + guard let row = fetchMemory(id: id) else { return } + context.delete(row) + save() + } + + func deleteAllMemories(projectId: UUID? = nil) { + if let projectId { + deleteMemoryRows(projectId: projectId) + } else { + let rows = (try? context.fetch(FetchDescriptor())) ?? [] + for row in rows { context.delete(row) } + } + save() + } + + private func deleteMemoryRows(projectId: UUID) { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.projectId == projectId } + ) + let rows = (try? context.fetch(descriptor)) ?? [] + for row in rows { context.delete(row) } + } +} diff --git a/RxCode/Services/ThreadStore.swift b/RxCode/Services/ThreadStore.swift index 1336d9d7..407f5ec0 100644 --- a/RxCode/Services/ThreadStore.swift +++ b/RxCode/Services/ThreadStore.swift @@ -7,8 +7,8 @@ import os /// AppState is the only caller; all reads/writes happen on the main actor. @MainActor final class ThreadStore { - private let logger = Logger(subsystem: "com.claudework", category: "ThreadStore") - private let context: ModelContext + let logger = Logger(subsystem: "com.claudework", category: "ThreadStore") + let context: ModelContext init(context: ModelContext) { self.context = context @@ -107,17 +107,6 @@ final class ThreadStore { return ((try? context.fetch(descriptor)) ?? []).map { $0.toItem() } } - /// Session ids of `[Code Review]` threads (manual or hook-spawned), - /// identified by their thread label. Used to keep review threads out of - /// briefings even for summaries persisted before review threads were - /// excluded at write time. Filters in memory rather than via `#Predicate` - /// to avoid SwiftData's optional-vs-non-optional comparison pitfalls on the - /// optional `threadLabel`. - func codeReviewThreadIds(label: String) -> Set { - let rows = (try? context.fetch(FetchDescriptor())) ?? [] - return Set(rows.filter { $0.threadLabel == label }.map { $0.id }) - } - func branchBriefingItem(projectId: UUID, branch: String) -> BranchBriefingItem? { fetchBranchBriefing(projectId: projectId, branch: branch)?.toItem() } @@ -821,151 +810,7 @@ final class ThreadStore { for row in rows { context.delete(row) } } - // MARK: - Memories - - func loadAllMemories() -> [MemoryRecord] { - let descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] - ) - return (try? context.fetch(descriptor)) ?? [] - } - - func loadAllMemorySnapshots() -> [MemoryVectorSnapshot] { - loadAllMemories().map { $0.toVectorSnapshot() } - } - - func fetchMemory(id: String) -> MemoryRecord? { - var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) - descriptor.fetchLimit = 1 - return (try? context.fetch(descriptor))?.first - } - - func upsertMemory( - id: String?, - content: String, - projectId: UUID?, - sessionId: String?, - sourceMessageId: UUID?, - kind: String, - scope: String, - vector: Data, - dim: Int - ) -> MemoryItem { - let memoryId = id ?? UUID().uuidString - let now = Date() - if let existing = fetchMemory(id: memoryId) { - existing.apply( - content: content, - projectId: projectId, - sessionId: sessionId, - sourceMessageId: sourceMessageId, - kind: kind, - scope: scope, - vector: vector, - dim: dim, - updatedAt: now - ) - save() - return existing.toItem() - } else { - let row = MemoryRecord( - id: memoryId, - content: content, - projectId: projectId, - sessionId: sessionId, - sourceMessageId: sourceMessageId, - createdAt: now, - updatedAt: now, - kind: kind, - scope: scope, - vector: vector, - dim: dim - ) - context.insert(row) - save() - return row.toItem() - } - } - - func touchMemories(ids: [String], at date: Date = .now) { - guard !ids.isEmpty else { return } - for id in ids { - fetchMemory(id: id)?.touch(at: date) - } - save() - } - - func deleteMemory(id: String) { - guard let row = fetchMemory(id: id) else { return } - context.delete(row) - save() - } - - func deleteAllMemories(projectId: UUID? = nil) { - if let projectId { - deleteMemoryRows(projectId: projectId) - } else { - let rows = (try? context.fetch(FetchDescriptor())) ?? [] - for row in rows { context.delete(row) } - } - save() - } - - private func deleteMemoryRows(projectId: UUID) { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.projectId == projectId } - ) - let rows = (try? context.fetch(descriptor)) ?? [] - for row in rows { context.delete(row) } - } - - // MARK: - Thread Embedding Chunks - - func loadAllEmbeddingChunks() -> [ThreadEmbeddingChunk] { - let descriptor = FetchDescriptor() - return (try? context.fetch(descriptor)) ?? [] - } - - func loadEmbeddingChunks(threadId: String) -> [ThreadEmbeddingChunk] { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.threadId == threadId }, - sortBy: [SortDescriptor(\.chunkIndex, order: .forward)] - ) - return (try? context.fetch(descriptor)) ?? [] - } - - /// Replace all chunks for a thread atomically. Old rows are deleted first - /// so re-indexing cannot leave orphans behind. - func replaceEmbeddingChunks(threadId: String, chunks: [ThreadEmbeddingChunk]) { - deleteEmbeddingChunkRows(threadId: threadId) - for chunk in chunks { - context.insert(chunk) - } - save() - } - - func deleteEmbeddingChunks(threadId: String) { - deleteEmbeddingChunkRows(threadId: threadId) - save() - } - - /// Wipe every persisted embedding chunk across all threads. - func deleteAllEmbeddingChunks() { - let descriptor = FetchDescriptor() - let rows = (try? context.fetch(descriptor)) ?? [] - for row in rows { context.delete(row) } - save() - } - - private func deleteEmbeddingChunkRows(threadId: String) { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.threadId == threadId } - ) - let rows = (try? context.fetch(descriptor)) ?? [] - for row in rows { context.delete(row) } - } - - private func save() { + func save() { guard context.hasChanges else { return } do { try context.save() } catch { logger.error("Save failed: \(error.localizedDescription)") } diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index f1e23d2a..fc9025fd 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -161,7 +161,8 @@ struct MainView: View { // is active, clear the terminal; otherwise open global search. if showRightSidebar, windowState.inspectorMode == .inspector, - windowState.inspectorTab == .terminal { + windowState.inspectorTab == .terminal + { windowState.clearTerminalRequest = UUID() } else { windowState.showGlobalSearch.toggle() @@ -343,7 +344,8 @@ struct MainView: View { // the docs-publishing skill into its system prompt on first send. if let projectId = request.projectId, let project = appState.projects.first(where: { $0.id == projectId }), - windowState.selectedProject?.id != projectId { + windowState.selectedProject?.id != projectId + { appState.selectProject(project, in: windowState) } appState.pendingDocsSetupProjectId = request.projectId ?? windowState.selectedProject?.id @@ -364,7 +366,8 @@ struct MainView: View { // injects the release skill into its system prompt on first send. if let projectId = request.projectId, let project = appState.projects.first(where: { $0.id == projectId }), - windowState.selectedProject?.id != projectId { + windowState.selectedProject?.id != projectId + { appState.selectProject(project, in: windowState) } appState.pendingReleaseSetupProjectId = request.projectId ?? windowState.selectedProject?.id diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index bfa2d12f..b32aa2ff 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -11,12 +11,14 @@ struct SettingsView: View { @State private var selectedTab = 0 @State private var showUserManual = false @State private var showOnboarding = false + @State private var showWhatsNew = false var body: some View { TabView(selection: $selectedTab) { GeneralSettingsTab( showUserManual: $showUserManual, - showOnboarding: $showOnboarding + showOnboarding: $showOnboarding, + showWhatsNew: $showWhatsNew ) .tabItem { Label("General", systemImage: "slider.horizontal.3") @@ -82,6 +84,12 @@ struct SettingsView: View { } .environment(appState) } + .sheet(isPresented: $showWhatsNew) { + WhatsNewSheet(features: WhatsNewFeature.all) { + showWhatsNew = false + } + .environment(appState) + } } } @@ -91,6 +99,7 @@ struct GeneralSettingsTab: View { @Environment(AppState.self) private var appState @Binding var showUserManual: Bool @Binding var showOnboarding: Bool + @Binding var showWhatsNew: Bool @State private var showThemePicker = false @AppStorage("showMenuBarExtra") private var showMenuBarExtra: Bool = true @@ -116,6 +125,7 @@ struct GeneralSettingsTab: View { Divider() VStack(alignment: .leading, spacing: 8) { onboardingSection + whatsNewSection helpSection sourceCodeSection } @@ -408,6 +418,39 @@ struct GeneralSettingsTab: View { .buttonStyle(.plain) } + private var whatsNewSection: some View { + Button { + showWhatsNew = true + } label: { + HStack(spacing: 10) { + Image(systemName: "wand.and.stars") + .font(.system(size: ClaudeTheme.size(14))) + .frame(width: 20) + VStack(alignment: .leading, spacing: 1) { + Text("What's New") + .font(.system(size: ClaudeTheme.size(13))) + .foregroundStyle(.primary) + Text("See the latest features and updates") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + private var helpSection: some View { Button { showUserManual = true diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index e6639869..ec4b4cf9 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -57,7 +57,7 @@ struct BriefingView: View { /// any summaries persisted before that exclusion existed. private func visibleThreadSummaryItems() -> [ThreadSummaryItem] { let knownIds = knownProjectIds - let reviewIds = appState.threadStore.codeReviewThreadIds(label: AppState.manualCodeReviewLabel) + let reviewIds = appState.codeReviewThreadIds return appState.threadStore.allThreadSummaryItems() .filter { knownIds.contains($0.projectId) && !reviewIds.contains($0.sessionId) } } diff --git a/RxCode/Views/Sidebar/ProjectChatRow.swift b/RxCode/Views/Sidebar/ProjectChatRow.swift index 9af05f80..266a5b80 100644 --- a/RxCode/Views/Sidebar/ProjectChatRow.swift +++ b/RxCode/Views/Sidebar/ProjectChatRow.swift @@ -104,6 +104,9 @@ struct ProjectChatRow: View { /// the nesting already conveys what they are). var showLabelChip: Bool = true var reviewDisclosure: ReviewDisclosure? = nil + /// Latest code-review verdict for this thread: `true` passed, `false` found + /// issues, `nil` not reviewed (no icon shown). + var reviewPassed: Bool? = nil @State private var isHovered = false @@ -151,6 +154,10 @@ struct ProjectChatRow: View { Spacer(minLength: 4) + if let reviewPassed { + reviewVerdictIcon(passed: reviewPassed) + } + if summary.isPinned { Image(systemName: "pin.fill") .font(.system(size: ClaudeTheme.size(9))) @@ -229,6 +236,17 @@ struct ProjectChatRow: View { } } + /// Code-review verdict badge: a green check when the latest review passed, + /// a red exclamation when it found issues. + @ViewBuilder + private func reviewVerdictIcon(passed: Bool) -> some View { + Image(systemName: passed ? "checkmark.seal.fill" : "exclamationmark.triangle.fill") + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(passed ? ClaudeTheme.statusSuccess : ClaudeTheme.statusError) + .help(passed ? "Code review passed" : "Code review found issues") + .accessibilityLabel(passed ? "Code review passed" : "Code review found issues") + } + /// Leading chevron that expands/collapses the nested review children, plus a /// review count / "reviewing" spinner. @ViewBuilder diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index 03984d51..a91c403e 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -739,7 +739,8 @@ private struct ProjectChatsList: View { indentLevel: indentLevel, titleOverride: titleOverride, showLabelChip: showLabelChip, - reviewDisclosure: reviewDisclosure + reviewDisclosure: reviewDisclosure, + reviewPassed: appState.reviewPassedBySession[sessionId] ) } } diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index 8d0d0b7a..a3e5aa45 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -1167,6 +1167,7 @@ } }, "Bash" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1787,6 +1788,12 @@ } } } + }, + "Code Review" : { + + }, + "Code Review for %@" : { + }, "Codex Usage" : { "localizations" : { @@ -4425,6 +4432,9 @@ } } } + }, + "Hide code reviews" : { + }, "Hide values" : { "localizations" : { @@ -6401,6 +6411,12 @@ } } } + }, + "Node.js" : { + + }, + "Node.js Configuration" : { + }, "Normal" : { "localizations" : { @@ -6735,6 +6751,7 @@ } }, "Package" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -6751,6 +6768,7 @@ } }, "Package Configuration" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -8530,6 +8548,9 @@ } } } + }, + "Show %lld code reviews" : { + }, "Sign in to Claude Code or Codex on your Mac to see usage." : { "localizations" : { @@ -8776,6 +8797,9 @@ } } } + }, + "Starting Code Review…" : { + }, "stdio" : { "localizations" : { @@ -9118,6 +9142,9 @@ } } } + }, + "The Mac is starting a Code Review thread for this branch." : { + }, "The Mac returned an empty response." : { "localizations" : { diff --git a/RxCodeMobile/Views/GlassThreadCard.swift b/RxCodeMobile/Views/GlassThreadCard.swift index c1551bf9..1220210d 100644 --- a/RxCodeMobile/Views/GlassThreadCard.swift +++ b/RxCodeMobile/Views/GlassThreadCard.swift @@ -11,18 +11,53 @@ struct GlassThreadCard: View { /// When false, uses Button with onSelect callback for selection-based navigation (iPad). var usesNavigationLink: Bool = true var onSelect: (() -> Void)? - + /// 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). + var titleOverride: String? = nil + /// Whether to show the `threadLabel` chip (hidden on review children since + /// the nesting already conveys what they are). + var showLabelChip: Bool = true + + // MARK: Review disclosure (Android-parity, in-card leading control) + + /// Number of nested code-review children this thread owns. Zero means no + /// disclosure control is shown. + var reviewCount: Int = 0 + /// Whether the nested reviews are currently expanded. + var isReviewExpanded: Bool = false + /// Whether any nested review is actively streaming (shows a spinner instead + /// of the count). + var isReviewStreaming: Bool = false + /// Toggles review expansion. When non-nil, an in-card disclosure control is + /// rendered at the leading edge (mirrors Android's `SessionCard`). + var onToggleReviews: (() -> Void)? + @Environment(\.colorScheme) private var colorScheme - + private var displayTitle: String { + if let titleOverride, !titleOverride.isEmpty { return titleOverride } let cleaned = ChatSession.stripAttachmentMarkers(from: session.title) return cleaned.isEmpty ? ChatSession.defaultTitle : cleaned } - + var body: some View { - // The UI-test identifier is applied directly on the button of each - // branch — applying it to an enclosing container does not reach the - // button element XCUITest queries. + Group { + if let onToggleReviews { + cardWithDisclosure(onToggleReviews: onToggleReviews) + } else { + plainCard + } + } + .padding(.leading, CGFloat(indentLevel) * 28) + } + + /// Standard card: the whole surface is one navigable button. + /// The UI-test identifier is applied directly on the button of each branch — + /// applying it to an enclosing container does not reach the button element + /// XCUITest queries. + @ViewBuilder + private var plainCard: some View { if usesNavigationLink { NavigationLink(value: session.id) { cardContent @@ -39,6 +74,70 @@ struct GlassThreadCard: View { .accessibilityIdentifier("thread-row-\(session.id)") } } + + /// Parent card with nested reviews: a leading disclosure control and the + /// navigable content sit side by side as independent tap targets sharing one + /// glass surface (SwiftUI can't reliably nest a button inside a button, so + /// they're siblings rather than nested). Mirrors Android's `SessionCard`. + private func cardWithDisclosure(onToggleReviews: @escaping () -> Void) -> some View { + HStack(spacing: 0) { + disclosureControl(onToggle: onToggleReviews) + .padding(.leading, 12) + + navigableContent + } + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isSelected ? ClaudeTheme.accent.opacity(0.15) : .clear) + } + .glassEffect( + isSelected + ? .regular.tint(ClaudeTheme.accent.opacity(0.3)).interactive() + : .regular.interactive(), + in: .rect(cornerRadius: 16) + ) + } + + @ViewBuilder + private var navigableContent: some View { + if usesNavigationLink { + NavigationLink(value: session.id) { cardContent } + .buttonStyle(.plain) + .accessibilityIdentifier("thread-row-\(session.id)") + } else { + Button { onSelect?() } label: { cardContent } + .buttonStyle(.plain) + .accessibilityIdentifier("thread-row-\(session.id)") + } + } + + /// Leading chevron + review-count column, tappable independently of the row. + private func disclosureControl(onToggle: @escaping () -> Void) -> some View { + Button { + onToggle() + } label: { + VStack(spacing: 2) { + Image(systemName: isReviewExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 12, weight: .bold)) + if isReviewStreaming { + ProgressView() + .controlSize(.mini) + .scaleEffect(0.7) + } else { + Text("\(reviewCount)") + .font(.system(size: 10, weight: .bold)) + .monospacedDigit() + } + } + .foregroundStyle(ClaudeTheme.accent) + .frame(width: 22) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isReviewExpanded ? "Hide code reviews" : "Show \(reviewCount) code reviews") + } private var cardContent: some View { HStack(spacing: 12) { @@ -60,7 +159,7 @@ struct GlassThreadCard: View { .foregroundStyle(.primary) .lineLimit(1) - if let label = session.threadLabel, !label.isEmpty { + if showLabelChip, let label = session.threadLabel, !label.isEmpty { Text(label) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(ClaudeTheme.accent) diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index d730ef1c..276a9644 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -401,38 +401,19 @@ struct SessionsList: View { .mapValues { $0.sorted { $0.updatedAt < $1.updatedAt } } } - /// A top-level thread row. When it has review children, an attached - /// disclosure bar hangs beneath it and reveals the nested reviews — every - /// top-level card stays aligned to the same leading edge. + /// A top-level thread row. When it owns review children, an in-card leading + /// disclosure control (chevron + count) toggles the nested reviews — mirrors + /// Android's `SessionCard`. Every card stays full-width and left-aligned. @ViewBuilder private func threadGroup(for session: SessionSummary) -> some View { let children = childrenByParent[session.id] ?? [] + let isExpanded = expandedReviewParentIds.contains(session.id) - if children.isEmpty { - threadCard(for: session) - } else { - let isExpanded = expandedReviewParentIds.contains(session.id) - - VStack(spacing: 6) { - threadCard(for: session) - reviewDisclosureBar(for: session, children: children, isExpanded: isExpanded) + threadCard(for: session, reviewChildren: children, isReviewExpanded: isExpanded) - if isExpanded { - VStack(spacing: 8) { - ForEach(Array(children.enumerated()), id: \.element.id) { index, child in - threadCard(for: child, indentLevel: 1, titleOverride: "Review \(index + 1)") - } - } - .overlay(alignment: .leading) { - // Vertical rail visually tying the reviews to their parent. - Capsule() - .fill(ClaudeTheme.accent.opacity(0.25)) - .frame(width: 2) - .padding(.vertical, 6) - .padding(.leading, 12) - } - .transition(.move(edge: .top).combined(with: .opacity)) - } + if isExpanded { + ForEach(Array(children.enumerated()), id: \.element.id) { index, child in + threadCard(for: child, indentLevel: 1, titleOverride: "Review \(index + 1)") } } } @@ -440,7 +421,9 @@ struct SessionsList: View { private func threadCard( for session: SessionSummary, indentLevel: Int = 0, - titleOverride: String? = nil + titleOverride: String? = nil, + reviewChildren: [SessionSummary] = [], + isReviewExpanded: Bool = false ) -> some View { GlassThreadCard( session: session, @@ -449,7 +432,11 @@ struct SessionsList: View { onSelect: usesSelection ? { selected = session.id } : nil, indentLevel: indentLevel, titleOverride: titleOverride, - showLabelChip: indentLevel == 0 + showLabelChip: indentLevel == 0, + reviewCount: reviewChildren.count, + isReviewExpanded: isReviewExpanded, + isReviewStreaming: reviewChildren.contains { $0.isStreaming }, + onToggleReviews: reviewChildren.isEmpty ? nil : { toggleReviews(for: session.id) } ) .glassEffectID(session.id, in: glassNamespace) .onAppear { @@ -460,59 +447,14 @@ struct SessionsList: View { } } - /// A slim, full-width control attached beneath a parent thread that owns - /// code reviews. Replaces the old chevron badge that floated in the left - /// margin and knocked every parent card out of alignment. - private func reviewDisclosureBar( - for session: SessionSummary, - children: [SessionSummary], - isExpanded: Bool - ) -> some View { - let isStreaming = children.contains { $0.isStreaming } - let countLabel = "\(children.count) \(children.count == 1 ? "Code Review" : "Code Reviews")" - - return Button { - withAnimation(.easeInOut(duration: 0.2)) { - if expandedReviewParentIds.contains(session.id) { - expandedReviewParentIds.remove(session.id) - } else { - expandedReviewParentIds.insert(session.id) - } - } - } label: { - HStack(spacing: 8) { - Image(systemName: "checklist") - .font(.system(size: 12, weight: .semibold)) - - Text(countLabel) - .font(.system(size: 13, weight: .semibold)) - - if isStreaming { - ProgressView() - .controlSize(.mini) - .scaleEffect(0.8) - } - - Spacer(minLength: 0) - - Image(systemName: "chevron.down") - .font(.system(size: 11, weight: .bold)) - .rotationEffect(.degrees(isExpanded ? 0 : -90)) - } - .foregroundStyle(ClaudeTheme.accent) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .frame(maxWidth: .infinity, alignment: .leading) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(ClaudeTheme.accent.opacity(0.08)) + private func toggleReviews(for parentID: String) { + withAnimation(.easeInOut(duration: 0.2)) { + if expandedReviewParentIds.contains(parentID) { + expandedReviewParentIds.remove(parentID) + } else { + expandedReviewParentIds.insert(parentID) } - .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } - .buttonStyle(.plain) - // Indented under the parent's content to read as its child. - .padding(.leading, 28) - .accessibilityLabel(isExpanded ? "Hide code reviews" : "Show \(children.count) code reviews") } private var usesDesktopSearch: Bool { From e21e89ef033e98a58d2a0a0356a536b4af64353a Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:29:21 +0800 Subject: [PATCH 12/14] feat: add manual commit actions --- .../Protocol/Payload+Autopilot.swift | 9 +- RxCode/App/AppState+Commit.swift | 112 ++++++++++++++++++ RxCode/App/AppState+CrossProjectSend.swift | 9 +- RxCode/App/AppState+MobileAutopilot.swift | 16 +++ RxCode/Resources/Localizable.xcstrings | 6 + .../Services/Hooks/hooks/CommitPushHook.swift | 8 +- .../Chat/RecentChatsSuggestionList.swift | 8 ++ RxCode/Views/MainView.swift | 6 + RxCode/Views/Sidebar/BriefingView.swift | 18 +++ RxCode/Views/Sidebar/HistoryListView.swift | 8 ++ RxCode/Views/Sidebar/ProjectChatRow.swift | 4 + RxCode/Views/Sidebar/ProjectListView.swift | 6 + RxCode/Views/Sidebar/ProjectTreeView.swift | 18 +++ .../app/rxlab/rxcode/proto/AutopilotModels.kt | 2 +- .../rxlab/rxcode/proto/AutopilotPayloads.kt | 2 + .../rxlab/rxcode/state/AutopilotService.kt | 18 +++ .../app/rxlab/rxcode/state/MobileAppState.kt | 8 ++ .../rxcode/ui/autopilot/ProjectActionsMenu.kt | 41 +++++++ .../ui/briefing/BriefingDetailScreen.kt | 7 +- .../app/rxlab/rxcode/ui/chat/ChatScreen.kt | 15 +++ .../rxcode/ui/sessions/SessionsScreen.kt | 18 +++ .../State/MobileAppState+Autopilot.swift | 18 +++ .../Autopilot/ProjectAutopilotMenu.swift | 15 +++ .../Views/MobileBriefingDetailView.swift | 31 ++++- .../Views/MobileChatView+Toolbar.swift | 18 +++ RxCodeMobile/Views/SessionsList.swift | 93 +++++++++++---- 26 files changed, 475 insertions(+), 39 deletions(-) create mode 100644 RxCode/App/AppState+Commit.swift diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift index a1431d80..b0f14096 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -120,6 +120,10 @@ public enum AutopilotOp: String, Codable, Sendable { // equivalent of the built-in Code Review hook). case projectCreateCodeReview case threadCreateCodeReview + // Manual commit actions. The desktop starts an agent turn: project commits + // all uncommitted files, thread commits only that thread's recorded files. + case projectCommitAll + case threadCommitFiles // Global search — one call returns on-device thread matches AND published // docs matches for the same query, so mobile gets a single combined result @@ -514,8 +518,9 @@ public struct AutopilotThreadBody: Codable, Sendable { public init(sessionId: String) { self.sessionId = sessionId } } -/// Result of `projectCreateCodeReview` / `threadCreateCodeReview`: the id of the -/// spawned `[Code Review]` thread, so the phone can navigate to it once it syncs. +/// Result of thread-spawning project actions such as code review and commit: +/// the id of the spawned or updated thread, so the phone can navigate to it once +/// it syncs. public struct AutopilotCodeReviewResult: Codable, Sendable { public let threadId: String public init(threadId: String) { self.threadId = threadId } diff --git a/RxCode/App/AppState+Commit.swift b/RxCode/App/AppState+Commit.swift new file mode 100644 index 00000000..73c368da --- /dev/null +++ b/RxCode/App/AppState+Commit.swift @@ -0,0 +1,112 @@ +import Foundation +import RxCodeCore + +/// Errors surfaced while starting a manual commit turn from a project, briefing, +/// or thread menu. +enum CommitFilesError: LocalizedError { + case unknownThread + case unknownProject + case sendFailed(String) + + var errorDescription: String? { + switch self { + case .unknownThread: + return "Couldn't find the thread to commit." + case .unknownProject: + return "Couldn't find the project to commit." + case .sendFailed(let message): + return "Couldn't start the commit.\n\n\(message)" + } + } +} + +extension AppState { + static let manualCommitLabel = "Commit" + + /// 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. + @discardableResult + func commitFilesForThread(sessionId: String) async throws -> String { + guard let summary = allSessionSummaries.first(where: { $0.id == sessionId }) + ?? threadStore.fetch(id: sessionId)?.toSummary() else { + throw CommitFilesError.unknownThread + } + guard projects.contains(where: { $0.id == summary.projectId }) else { + throw CommitFilesError.unknownProject + } + + var seen = Set() + let changedFiles = threadStore.fetchFileEdits(sessionId: sessionId).compactMap { edit in + seen.insert(edit.path).inserted ? edit.path : nil + } + + let result = try await sendCrossProject( + projectId: nil, + threadId: sessionId, + prompt: Self.threadCommitPrompt(changedFiles: changedFiles), + permissionMode: .auto, + waitForResponse: false, + timeoutSeconds: 600, + setupKind: HookSetupKind.commitPush + ) + if let error = result.error { throw CommitFilesError.sendFailed(error) } + return result.threadId + } + + /// Start a commit-only thread for all current uncommitted project changes. + /// Used by project rows and briefing cards. + @discardableResult + func commitAllChangesForProject(project: Project) async throws -> String { + let result = try await sendCrossProject( + projectId: project.id, + threadId: nil, + prompt: Self.projectCommitPrompt(projectName: project.name), + permissionMode: .auto, + waitForResponse: false, + timeoutSeconds: 600, + threadLabel: Self.manualCommitLabel, + setupKind: HookSetupKind.commitPush + ) + if let error = result.error { throw CommitFilesError.sendFailed(error) } + return result.threadId + } + + static func threadCommitPrompt(changedFiles: [String]) -> String { + let fileList = changedFiles.isEmpty + ? "(no recorded file edits — inspect this thread and the working tree, then commit only the files that belong to this thread)" + : changedFiles.map { "- \($0)" }.joined(separator: "\n") + + return """ + Commit the files changed by this thread. + + Files changed by this thread: + \(fileList) + + Steps: + 1. Inspect `git status` and the relevant diffs. + 2. Stage only the files that belong to this thread. Do not stage unrelated project changes. + 3. Create a local commit with a clear Conventional Commit message. + 4. Report the commit hash and the files committed. + + Do not push the commit unless the user explicitly asks for a push. + Do not make further code changes beyond what is needed to commit these files. + """ + } + + static func projectCommitPrompt(projectName: String) -> String { + """ + Commit all current uncommitted changes for project `\(projectName)`. + + Steps: + 1. Inspect `git status` and the relevant diffs. + 2. Stage all modified, deleted, and untracked files that belong to the current project change set. + 3. Create a local commit with a clear Conventional Commit message. + 4. Report the commit hash and the files committed. + + If there are no uncommitted changes, report that clearly and do not create an empty commit. + Do not push the commit unless the user explicitly asks for a push. + Do not make further code changes beyond what is needed to commit the current changes. + """ + } +} diff --git a/RxCode/App/AppState+CrossProjectSend.swift b/RxCode/App/AppState+CrossProjectSend.swift index 15f635ce..29cd64f6 100644 --- a/RxCode/App/AppState+CrossProjectSend.swift +++ b/RxCode/App/AppState+CrossProjectSend.swift @@ -41,7 +41,8 @@ extension AppState { timeoutSeconds: TimeInterval = 120, parentThreadId: String? = nil, threadLabel: String? = nil, - skipHooks: Bool = false + skipHooks: Bool = false, + setupKind: String? = nil ) async throws -> CrossProjectSendResult { // Resolve target project + thread. let resolvedProject: Project @@ -108,6 +109,9 @@ extension AppState { // id before returning so the caller's agent never sees `pending-...` // (which it can't use to follow up via `get_thread_messages` etc.). let postSendKey = window.currentSessionId ?? resolvedThreadId ?? "" + if let setupKind { + setupSessionKeys[setupKind, default: []].insert(postSendKey) + } let resolvedThreadIdForReturn: String if postSendKey.hasPrefix("pending-") { // Cap the rename wait at the request's timeout so we still honor @@ -121,6 +125,9 @@ extension AppState { } else { resolvedThreadIdForReturn = postSendKey } + if let setupKind, resolvedThreadIdForReturn != postSendKey { + setupSessionKeys[setupKind, default: []].insert(resolvedThreadIdForReturn) + } // Stamp linkage (parent thread / label / skip-hooks) onto the freshly // created thread now that its real id is known. Only for new threads — diff --git a/RxCode/App/AppState+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift index 5c3b10d0..fb3980ed 100644 --- a/RxCode/App/AppState+MobileAutopilot.swift +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -356,6 +356,22 @@ extension AppState { let threadId = try await createCodeReviewForThread(sessionId: body.sessionId) return try encoder.encode(AutopilotCodeReviewResult(threadId: threadId)) + case .projectCommitAll: + // Same as the desktop project/briefing "Commit All Changes" action: + // start a commit-only thread for the current project worktree. + let body = try decodeAutopilotBody(request, as: AutopilotProjectBody.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 commitAllChangesForProject(project: project) + 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) + let threadId = try await commitFilesForThread(sessionId: body.sessionId) + return try encoder.encode(AutopilotCodeReviewResult(threadId: threadId)) + case .projectSecretsDownload: let body = try decodeAutopilotBody(request, as: AutopilotProjectSecretsDownloadBody.self) guard let project = projects.first(where: { $0.id == body.projectId }) else { diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index a267288c..5b1979ac 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -3390,6 +3390,12 @@ } } } + }, + "Commit All Changes" : { + + }, + "Commit Files" : { + }, "Commit message" : { "localizations" : { diff --git a/RxCode/Services/Hooks/hooks/CommitPushHook.swift b/RxCode/Services/Hooks/hooks/CommitPushHook.swift index 9282e971..46f55a61 100644 --- a/RxCode/Services/Hooks/hooks/CommitPushHook.swift +++ b/RxCode/Services/Hooks/hooks/CommitPushHook.swift @@ -21,10 +21,6 @@ final class CommitPushHook: Hook { private let logger = Logger(subsystem: "com.claudework", category: "CommitPushHook") func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { - let hooks = await controller.enabledHookProfiles(projectId: payload.project.id, trigger: .afterSessionStop) - .filter { $0.action == .commitPush } - guard let hook = hooks.first else { return .ignored } - // Loop guard FIRST, so the commit turn always consumes its marker even if // that turn errored — otherwise a stale marker would skip the next real // turn's commit. @@ -34,6 +30,10 @@ final class CommitPushHook: Hook { return .ignored } + let hooks = await controller.enabledHookProfiles(projectId: payload.project.id, trigger: .afterSessionStop) + .filter { $0.action == .commitPush } + guard let hook = hooks.first else { return .ignored } + guard payload.reason == .completed, !payload.turnDidError else { return .ignored } // Defer while the user still has queued messages — they'll run as further diff --git a/RxCode/Views/Chat/RecentChatsSuggestionList.swift b/RxCode/Views/Chat/RecentChatsSuggestionList.swift index cf99c013..f9403126 100644 --- a/RxCode/Views/Chat/RecentChatsSuggestionList.swift +++ b/RxCode/Views/Chat/RecentChatsSuggestionList.swift @@ -149,6 +149,14 @@ 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/MainView.swift b/RxCode/Views/MainView.swift index fc9025fd..a22f0ae1 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -506,6 +506,12 @@ 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 ec4b4cf9..72f2c2b5 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -647,6 +647,18 @@ struct BriefingView: View { } } + /// Start a commit-only thread for all current project changes and open it. + private func startCommitAll(for project: Project) { + Task { + if windowState.selectedProject?.id != project.id { + appState.selectProject(project, in: windowState) + } + if let threadId = try? await appState.commitAllChangesForProject(project: project) { + appState.selectSession(id: threadId, in: windowState) + } + } + } + private func cardMenu(for group: BriefingGroup, project: Project) -> some View { Menu { Button { @@ -674,6 +686,12 @@ struct BriefingView: View { Label("Code Review for \(group.branch)", systemImage: "checklist") } + Button { + startCommitAll(for: project) + } label: { + Label("Commit All Changes", systemImage: "checkmark.circle") + } + let hookItems = appState.projectContextMenuItems(for: project) if !hookItems.isEmpty { Divider() diff --git a/RxCode/Views/Sidebar/HistoryListView.swift b/RxCode/Views/Sidebar/HistoryListView.swift index f71baf45..acf0b64b 100644 --- a/RxCode/Views/Sidebar/HistoryListView.swift +++ b/RxCode/Views/Sidebar/HistoryListView.swift @@ -224,6 +224,14 @@ 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 266a5b80..f416321c 100644 --- a/RxCode/Views/Sidebar/ProjectChatRow.swift +++ b/RxCode/Views/Sidebar/ProjectChatRow.swift @@ -95,6 +95,7 @@ struct ProjectChatRow: View { let onToggleArchive: () -> Void let onDelete: () -> Void let onCodeReview: () -> Void + let onCommitFiles: () -> Void let hookMenuItems: [HookMenuItem] /// Nesting depth; review children render one level in from their parent. var indentLevel: Int = 0 @@ -215,6 +216,9 @@ struct ProjectChatRow: View { Button { onCodeReview() } label: { Label("Code Review for this thread", systemImage: "checklist") } + Button { onCommitFiles() } label: { + Label("Commit Files", systemImage: "checkmark.circle") + } if !hookMenuItems.isEmpty { Divider() HookContextMenuItems(items: hookMenuItems) diff --git a/RxCode/Views/Sidebar/ProjectListView.swift b/RxCode/Views/Sidebar/ProjectListView.swift index c6e6c5ba..d39b4264 100644 --- a/RxCode/Views/Sidebar/ProjectListView.swift +++ b/RxCode/Views/Sidebar/ProjectListView.swift @@ -69,6 +69,12 @@ struct ProjectListView: 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/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index a91c403e..ee6bbfe8 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -274,6 +274,13 @@ struct ProjectTreeView: View { onCodeReview: { startBranchCodeReview(for: project) }, + onCommitAll: { + Task { + if let threadId = try? await appState.commitAllChangesForProject(project: project) { + appState.selectSession(id: threadId, in: windowState) + } + } + }, hookMenuItems: appState.projectContextMenuItems(for: project) ) @@ -321,6 +328,7 @@ private struct ProjectTreeRow: View { let onDelete: () -> Void let onNewChat: () -> Void let onCodeReview: () -> Void + let onCommitAll: () -> Void let hookMenuItems: [HookMenuItem] @State private var isHovered = false @@ -469,6 +477,9 @@ private struct ProjectTreeRow: View { 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", @@ -735,6 +746,13 @@ private struct ProjectChatsList: View { } } }, + onCommitFiles: { + Task { + if let threadId = try? await appState.commitFilesForThread(sessionId: sessionId) { + appState.selectSession(id: threadId, in: windowState) + } + } + }, hookMenuItems: appState.threadContextMenuItems(for: summary), indentLevel: indentLevel, titleOverride: titleOverride, diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt index 9cd01397..c9cd0a33 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt @@ -505,7 +505,7 @@ data class AutopilotProjectStatus( @Serializable data class AutopilotPullRequestResult(val url: String) -/** Result of `projectCreateCodeReview` / `threadCreateCodeReview`: the id of the spawned `[Code Review]` thread. */ +/** Result of thread-spawning project actions such as code review and commit. */ @Serializable data class AutopilotCodeReviewResult(val threadId: String) 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 da059980..22765a94 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 @@ -109,6 +109,8 @@ enum class AutopilotOp(val wire: String) { // Code review (desktop-mediated): spawn a `[Code Review]` thread on the Mac. PROJECT_CREATE_CODE_REVIEW("projectCreateCodeReview"), THREAD_CREATE_CODE_REVIEW("threadCreateCodeReview"), + PROJECT_COMMIT_ALL("projectCommitAll"), + THREAD_COMMIT_FILES("threadCommitFiles"), // Global search — one call returns thread matches AND published-docs matches. SEARCH_THREADS_AND_DOCS("searchThreadsAndDocs"), 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 e5ac3bce..60101f76 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 @@ -510,6 +510,24 @@ class AutopilotService( ) ).threadId + /** Ask the Mac to start a commit-only thread for all current project changes. */ + suspend fun requestProjectCommitAll(projectId: UUID): String = + decodeResult( + rawCall( + AutopilotDomain.PROJECT, AutopilotOp.PROJECT_COMMIT_ALL, + encodeBody(AutopilotProjectBody(projectId)), + ) + ).threadId + + /** Ask the Mac to commit only the files recorded for one thread. */ + suspend fun requestThreadCommitFiles(sessionId: String): String = + decodeResult( + rawCall( + AutopilotDomain.PROJECT, AutopilotOp.THREAD_COMMIT_FILES, + encodeBody(AutopilotThreadBody(sessionId)), + ) + ).threadId + /** * Relay already-decrypted secret files for the Mac to write into the project * folder. Decryption happens on-device first (see [SecretsManager]); this only 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 8bbffae1..38c01718 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 @@ -852,6 +852,14 @@ class MobileAppState @Inject constructor( suspend fun requestThreadCreateCodeReview(sessionId: String): String = autopilot.requestThreadCreateCodeReview(sessionId) + /** Start a commit-only thread on the Mac for all current project changes. */ + suspend fun requestProjectCommitAll(projectId: UUID): String = + autopilot.requestProjectCommitAll(projectId) + + /** Ask the Mac to commit only the files recorded for one thread. */ + suspend fun requestThreadCommitFiles(sessionId: String): String = + autopilot.requestThreadCommitFiles(sessionId) + /** * 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/ProjectActionsMenu.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt index d39366a4..038f0346 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 @@ -7,6 +7,7 @@ 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 @@ -97,6 +98,7 @@ fun ProjectActionsMenu( var showDeleteConfirm by remember { mutableStateOf(false) } var isCreatingPR by remember { mutableStateOf(false) } var isCreatingReview by remember { mutableStateOf(false) } + var isCommittingAll by remember { mutableStateOf(false) } var info by remember { mutableStateOf(null) } // Only repo-backed projects have autopilot state to load. @@ -144,12 +146,37 @@ fun ProjectActionsMenu( } } + 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 + } + } + } + IconButton(onClick = { menuOpen = true }) { Icon(Icons.Outlined.MoreVert, contentDescription = "Project actions") } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Commit All Changes") }, + leadingIcon = { Icon(Icons.Outlined.CheckCircle, contentDescription = null) }, + enabled = !isCommittingAll, + onClick = { commitAllChanges() }, + ) + if (hasRepo) { + HorizontalDivider() val loaded = status if (loaded == null) { DropdownMenuItem( @@ -300,6 +327,20 @@ fun ProjectActionsMenu( ) } + if (isCommittingAll) { + AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + title = { Text("Committing Changes…") }, + 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.") + } + }, + ) + } + info?.let { message -> AlertDialog( onDismissRequest = { info = null }, diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt index 5c2c55d0..97092fe2 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt @@ -118,9 +118,10 @@ fun BriefingDetailScreen( IconButton(onClick = onOpenSearch) { Icon(Icons.Outlined.Search, contentDescription = "Search") } - // Autopilot / GitHub context menu, 1:1 with iOS - // MobileBriefingDetailView. Repo-gated like the desktop menu. - project?.takeIf { !it.gitHubRepo.isNullOrEmpty() }?.let { repoProject -> + // Project actions, 1:1 with iOS MobileBriefingDetailView. + // GitHub/autopilot items stay gated inside the menu, while + // local actions such as commit remain available. + project?.let { repoProject -> ProjectActionsMenu( project = repoProject, branch = groupKey.branch, 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 d2bdadec..34e40292 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,6 +37,7 @@ 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 @@ -296,6 +297,20 @@ fun ChatScreen( }, 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) }, + ) 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 69d14df8..09fbc0f7 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 @@ -133,6 +133,16 @@ fun SessionsScreen( } } + fun startThreadCommit(sessionId: String) { + scope.launch { + runCatching { + val threadId = viewModel.requestThreadCommitFiles(sessionId) + viewModel.requestSnapshot("commit_started") + onNewThread(threadId) + } + } + } + Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { @@ -219,6 +229,7 @@ fun SessionsScreen( }, onDelete = { deleteTarget = parent }, onCodeReview = { startThreadReview(parent.id) }, + onCommitFiles = { startThreadCommit(parent.id) }, reviewCount = children.size, isExpanded = expanded, isReviewing = children.any { it.isStreaming }, @@ -251,6 +262,7 @@ fun SessionsScreen( }, onDelete = { deleteTarget = child }, onCodeReview = { startThreadReview(child.id) }, + onCommitFiles = { startThreadCommit(child.id) }, indentLevel = 1, titleOverride = "Review ${index + 1}", showLabel = false, @@ -360,6 +372,7 @@ private fun SessionCard( onArchive: () -> Unit, onDelete: () -> Unit, onCodeReview: () -> Unit = {}, + onCommitFiles: () -> Unit = {}, reviewCount: Int = 0, isExpanded: Boolean = false, isReviewing: Boolean = false, @@ -461,6 +474,11 @@ private fun SessionCard( 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() }, + ) DropdownMenuItem( text = { Text("Rename") }, leadingIcon = { Icon(Icons.Outlined.DriveFileRenameOutline, contentDescription = null) }, diff --git a/RxCodeMobile/State/MobileAppState+Autopilot.swift b/RxCodeMobile/State/MobileAppState+Autopilot.swift index d23c7fc4..cea75b8d 100644 --- a/RxCodeMobile/State/MobileAppState+Autopilot.swift +++ b/RxCodeMobile/State/MobileAppState+Autopilot.swift @@ -468,6 +468,24 @@ extension MobileAppState { as: AutopilotCodeReviewResult.self).threadId } + /// Asks the Mac to start a commit-only thread for all current uncommitted + /// project changes. Returns the thread id. + @discardableResult + func requestProjectCommitAll(projectId: UUID) async throws -> String { + try await autopilotSend(.project, .projectCommitAll, + body: AutopilotProjectBody(projectId: projectId), + as: AutopilotCodeReviewResult.self).threadId + } + + /// Asks the Mac to commit only the files recorded for one thread. Returns the + /// updated thread id. + @discardableResult + func requestThreadCommitFiles(sessionId: String) async throws -> String { + try await autopilotSend(.project, .threadCommitFiles, + body: AutopilotThreadBody(sessionId: sessionId), + as: AutopilotCodeReviewResult.self).threadId + } + /// Downloads the chosen environment into the project folder. The phone /// decrypts on-device with its passkey-derived KEK (the same iCloud-synced /// credential the Mac uses) — running the phone's own passkey ceremony — then diff --git a/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift index 992e0633..27207408 100644 --- a/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift +++ b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift @@ -55,12 +55,18 @@ struct ProjectAutopilotMenuItems: View { /// menu). Spawns a `[Code Review]` thread on the Mac reviewing the branch. var isCreatingReview: Bool = false var onCodeReview: () -> Void = {} + /// Manual commit support. Starts a commit-only thread on the Mac that + /// commits all current project changes. + var isCommittingAll: Bool = false + var onCommitAll: () -> Void = {} var body: some View { + commitAllItem() // Mirror the desktop guard: no repo → no autopilot items. if project.gitHubRepo == nil { EmptyView() } else if let status { + Divider() secretsItem(status) docsItem(status) releaseItem(status) @@ -74,6 +80,15 @@ struct ProjectAutopilotMenuItems: View { } } + private func commitAllItem() -> some View { + Button { + onCommitAll() + } label: { + Label("Commit All Changes", systemImage: "checkmark.circle") + } + .disabled(isCommittingAll) + } + @ViewBuilder private func secretsItem(_ status: AutopilotProjectStatus) -> some View { if status.hasSecrets { diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index fcec8a74..b5db391e 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -14,6 +14,7 @@ struct MobileBriefingDetailView: View { @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). @State private var autopilotStatus: AutopilotProjectStatus? @@ -53,7 +54,7 @@ struct MobileBriefingDetailView: View { if showsActionsMenu { ToolbarItem(placement: .topBarTrailing) { Menu { - if let project, project.gitHubRepo != nil { + if let project { ProjectAutopilotMenuItems( project: project, status: autopilotStatus, @@ -69,7 +70,9 @@ struct MobileBriefingDetailView: View { isCreatingPR: isCreatingPR, onCreatePR: { createPullRequest(project: project) }, isCreatingReview: isCreatingReview, - onCodeReview: { createCodeReview(project: project) } + onCodeReview: { createCodeReview(project: project) }, + isCommittingAll: isCommittingProject, + onCommitAll: { commitProjectChanges(project: project) } ) if gitHubURL != nil { Divider() } } @@ -128,6 +131,11 @@ struct MobileBriefingDetailView: View { 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." + ) } private var group: GroupedBriefing? { @@ -152,7 +160,7 @@ struct MobileBriefingDetailView: View { /// Show the ellipsis menu when there's an autopilot-capable repo or a GitHub /// link to surface. private var showsActionsMenu: Bool { - project?.gitHubRepo != nil || gitHubURL != nil + project != nil || gitHubURL != nil } /// GitHub destination for the "Open on GitHub" action. Prefers the pull @@ -234,6 +242,23 @@ struct MobileBriefingDetailView: View { } } + /// 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 { diff --git a/RxCodeMobile/Views/MobileChatView+Toolbar.swift b/RxCodeMobile/Views/MobileChatView+Toolbar.swift index 79732b4f..ff2b566b 100644 --- a/RxCodeMobile/Views/MobileChatView+Toolbar.swift +++ b/RxCodeMobile/Views/MobileChatView+Toolbar.swift @@ -34,6 +34,11 @@ extension MobileChatView { } label: { Label("Code Review", systemImage: "checklist") } + Button { + startCommitFiles() + } label: { + Label("Commit Files", systemImage: "checkmark.circle") + } Divider() Button { showingRenameSheet = true @@ -125,6 +130,19 @@ extension MobileChatView { } } + /// Ask the Mac to commit only the files recorded for this thread. + func startCommitFiles() { + let sid = sessionID + let projectID = currentProjectID + Task { + guard let threadId = try? await state.requestThreadCommitFiles(sessionId: sid) else { return } + await state.refreshSnapshot() + if let projectID { + state.pendingDeepLink = MobileDeepLink(sessionID: threadId, projectID: projectID) + } + } + } + func performArchive() { Task { await state.archiveThread(sessionID: sessionID) } onClose() diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index 276a9644..b3dc070e 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -33,6 +33,7 @@ struct SessionsList: View { @State private var showingSearch = false @State private var isCreatingPR = false @State private var isCreatingReview = false + @State private var isCommittingProject = false @Namespace private var glassNamespace // Autopilot actions (1:1 with the desktop project menu), moved here from the @@ -144,6 +145,11 @@ struct SessionsList: View { 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." + ) } /// Ask the Mac to open a PR for the project's current branch, then open it @@ -200,6 +206,36 @@ struct SessionsList: View { } } + /// Ask the Mac to start a commit-only thread for all project changes, then + /// open it once it syncs. + 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() + selected = threadId + } catch { + autopilotInfo = AutopilotMenuInfo(text: error.localizedDescription, isError: true) + } + } + } + + /// Ask the Mac to commit only the files recorded for a single thread. + private func commitThreadFiles(sessionID: String) { + Task { + do { + let threadId = try await state.requestThreadCommitFiles(sessionId: sessionID) + await state.refreshSnapshot() + selected = threadId + } catch { + autopilotInfo = AutopilotMenuInfo(text: error.localizedDescription, isError: true) + } + } + } + // MARK: - Toolbar @ToolbarContentBuilder @@ -207,32 +243,33 @@ struct SessionsList: View { if let project { ToolbarItem(placement: .topBarTrailing) { Menu { - // Autopilot actions only apply to repo-backed projects. - if project.gitHubRepo != nil { - 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) - } + 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) } - ) - Divider() - } + }, + isCreatingReview: isCreatingReview, + onCodeReview: { + if let branch = currentBranch { + createBranchCodeReview(project: project, branch: branch) + } + }, + isCommittingAll: isCommittingProject, + onCommitAll: { + commitProjectChanges(project: project) + } + ) + Divider() Button(role: .destructive) { showingDeleteProjectConfirm = true } label: { @@ -317,6 +354,12 @@ struct SessionsList: View { Label("Code Review", systemImage: "checklist") } + Button { + commitThreadFiles(sessionID: session.id) + } label: { + Label("Commit Files", systemImage: "checkmark.circle") + } + Button { Task { await state.archiveThread(sessionID: session.id) } } label: { From 149ff70e11f40f5ec6b2af45ed4bb78848cba729 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:52:11 +0800 Subject: [PATCH 13/14] fix: review model fix and update prompt --- .../RxCodeCore/Hooks/HookController.swift | 7 +-- .../RxCodeCore/Models/HookProfile.swift | 5 +- .../Hooks/AppStateHookController.swift | 53 +++++++++++++++++-- .../Services/Hooks/hooks/CodeReviewHook.swift | 9 ++-- .../Services/Hooks/hooks/CommitPushHook.swift | 6 +-- .../Views/Hooks/HookProfileDetailForm.swift | 20 +++++-- 6 files changed, 82 insertions(+), 18 deletions(-) diff --git a/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift index 78a68917..229cb2b3 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookController.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookController.swift @@ -44,9 +44,9 @@ public protocol HookController: AnyObject { /// Whether the thread should skip all lifecycle hooks (e.g. a review thread). func threadSkipsHooks(sessionId: String) -> Bool - /// The model id stored on a thread, if any (used as the review thread's - /// default model). - func threadModel(sessionId: String) -> String? + /// Resolve a stored hook model selection into the provider/model pair used + /// for a spawned agent thread. Empty selections inherit the reviewed thread. + func resolveAgentModelSelection(storedModel: String?, fallbackSessionId: String) -> (provider: AgentProvider, model: String)? /// Distinct paths of files edited during the thread. func changedFilePaths(sessionId: String) -> [String] /// The first user prompt text of a thread, if any. @@ -62,6 +62,7 @@ public protocol HookController: AnyObject { projectId: UUID, parentThreadId: String, label: String, + agentProvider: AgentProvider?, model: String?, prompt: String, timeoutSeconds: TimeInterval diff --git a/Packages/Sources/RxCodeCore/Models/HookProfile.swift b/Packages/Sources/RxCodeCore/Models/HookProfile.swift index 8d831a88..1bd61217 100644 --- a/Packages/Sources/RxCodeCore/Models/HookProfile.swift +++ b/Packages/Sources/RxCodeCore/Models/HookProfile.swift @@ -56,8 +56,9 @@ public enum HookAction: String, Codable, Sendable, CaseIterable, Hashable { /// Per-hook configuration for the `.codeReview` action. public struct CodeReviewConfig: Codable, Sendable, Hashable { - /// Model id for the review thread. Empty/`nil` ⇒ inherit the reviewed - /// thread's model. + /// Provider-qualified model key for the review thread (`:`). + /// Empty/`nil` ⇒ inherit the reviewed thread's model. Older bare model ids + /// are still accepted by the app and resolved against the available model list. public var model: String? /// Optional extra guidance appended to the reviewer's prompt. public var instructions: String? diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index f87bf783..8270c55b 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -95,12 +95,55 @@ final class AppStateHookController: HookController { return app.threadStore.fetch(id: sessionId)?.skipHooks ?? false } - func threadModel(sessionId: String) -> String? { + func resolveAgentModelSelection(storedModel: String?, fallbackSessionId: String) -> (provider: AgentProvider, model: String)? { guard let app else { return nil } - if let model = app.allSessionSummaries.first(where: { $0.id == sessionId })?.model { - return model + + let fallback: (provider: AgentProvider, model: String)? = { + if let summary = app.allSessionSummaries.first(where: { $0.id == fallbackSessionId }), + let model = summary.model { + return (summary.agentProvider, model) + } + if let session = app.threadStore.fetch(id: fallbackSessionId), + let model = session.model { + return (session.toSummary().agentProvider, model) + } + return nil + }() + + guard let trimmed = storedModel?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty + else { + return fallback + } + + if let separator = trimmed.firstIndex(of: ":") { + let rawProvider = String(trimmed[.. [String] { @@ -153,6 +196,7 @@ final class AppStateHookController: HookController { projectId: UUID, parentThreadId: String, label: String, + agentProvider: AgentProvider?, model: String?, prompt: String, timeoutSeconds: TimeInterval @@ -163,6 +207,7 @@ final class AppStateHookController: HookController { projectId: projectId, threadId: nil, prompt: prompt, + agentProvider: agentProvider, model: model, // Auto mode lets the reviewer run unattended, bypassing almost // all permission prompts so it can freely inspect the repo (read diff --git a/RxCode/Services/Hooks/hooks/CodeReviewHook.swift b/RxCode/Services/Hooks/hooks/CodeReviewHook.swift index 340d3c77..b5324ca9 100644 --- a/RxCode/Services/Hooks/hooks/CodeReviewHook.swift +++ b/RxCode/Services/Hooks/hooks/CodeReviewHook.swift @@ -60,8 +60,10 @@ final class CodeReviewHook: Hook { } let task = controller.firstUserPrompt(sessionId: payload.sessionKey) ?? "(task unknown)" - let model = hook.codeReview?.model?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedModel = (model?.isEmpty == false) ? model : controller.threadModel(sessionId: payload.sessionId) + let selection = controller.resolveAgentModelSelection( + storedModel: hook.codeReview?.model, + fallbackSessionId: payload.sessionId + ) let prompt = reviewPrompt( task: task, changedFiles: changedFiles, @@ -95,7 +97,8 @@ final class CodeReviewHook: Hook { projectId: payload.project.id, parentThreadId: payload.sessionId, label: "Code Review", - model: resolvedModel, + agentProvider: selection?.provider, + model: selection?.model, prompt: prompt, timeoutSeconds: Self.reviewTimeout ) diff --git a/RxCode/Services/Hooks/hooks/CommitPushHook.swift b/RxCode/Services/Hooks/hooks/CommitPushHook.swift index 46f55a61..ba178e53 100644 --- a/RxCode/Services/Hooks/hooks/CommitPushHook.swift +++ b/RxCode/Services/Hooks/hooks/CommitPushHook.swift @@ -4,8 +4,8 @@ import RxCodeCore /// Built-in `.commitPush` hook. On a clean session stop, if the project has an /// enabled Commit & Push hook, it sends a follow-up message into the same thread -/// instructing the agent to commit the changed files and push (the agent decides -/// new vs. existing branch). +/// instructing the agent to commit the changed files on the current branch and +/// push them. /// /// Loop prevention: the follow-up commit turn ends and re-enters this hook. The /// hook marks the session via `markSetupSession(.commitPush)` before sending, and @@ -86,7 +86,7 @@ final class CommitPushHook: Hook { Steps: 1. Stage the changed files and create a commit with a clear, conventional commit message describing the change. - 2. Choose an appropriate branch — reuse the current branch if suitable, or create a new branch if that is more appropriate — and push it to the remote (set upstream if needed). + 2. Check the current branch. If you are already on a branch, commit on that branch; do not create a new branch. If there is no current branch, create an appropriate branch before committing. Push the branch to the remote and set upstream if needed. 3. Report the branch name and the pushed commit. Do not make further code changes beyond what is needed to commit and push. diff --git a/RxCode/Views/Hooks/HookProfileDetailForm.swift b/RxCode/Views/Hooks/HookProfileDetailForm.swift index b91f9392..dede1687 100644 --- a/RxCode/Views/Hooks/HookProfileDetailForm.swift +++ b/RxCode/Views/Hooks/HookProfileDetailForm.swift @@ -76,8 +76,8 @@ struct HookProfileDetailForm: View { Text("Same as thread").tag("") ForEach(appState.availableAgentModelSections(), id: \.id) { section in Section(section.title) { - ForEach(section.models, id: \.id) { model in - Text(model.displayName).tag(model.id) + ForEach(section.models, id: \.key) { model in + Text(model.displayName).tag(model.key) } } } @@ -109,7 +109,7 @@ struct HookProfileDetailForm: View { private var codeReviewModelBinding: Binding { Binding( - get: { hook.codeReview?.model ?? "" }, + get: { normalizedCodeReviewModelTag(hook.codeReview?.model ?? "") }, set: { newValue in var cfg = hook.codeReview ?? CodeReviewConfig() cfg.model = newValue.isEmpty ? nil : newValue @@ -118,6 +118,20 @@ struct HookProfileDetailForm: View { ) } + private func normalizedCodeReviewModelTag(_ storedValue: String) -> String { + let trimmed = storedValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + let models = appState.availableAgentModelSections().flatMap(\.models) + if models.contains(where: { $0.key == trimmed }) { + return trimmed + } + if let match = models.first(where: { $0.id == trimmed }) { + return match.key + } + return trimmed + } + private var codeReviewInstructionsBinding: Binding { Binding( get: { hook.codeReview?.instructions ?? "" }, From 0a6d036e29098b5224f368d8cf8d8d67f38cee61 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:47:39 +0800 Subject: [PATCH 14/14] test(message-list): assert pinned turn follows bottom after fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c466199 made the scroll anchor stay sticky through non-user layout settles, so isNearBottom no longer flips false→true during a pinned turn. The pinned-turn integration test asserted that obsolete flicker and broke CI. Rewrite it to assert the new intended behavior: once the streaming turn fills the reserved space, the list keeps following the bottom (the pin releases without stranding the view). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MessageListPinnedTurnSwiftUITests.swift | 44 +++++-------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/Packages/Tests/MessageListTests/MessageListPinnedTurnSwiftUITests.swift b/Packages/Tests/MessageListTests/MessageListPinnedTurnSwiftUITests.swift index d7bee799..77f85024 100644 --- a/Packages/Tests/MessageListTests/MessageListPinnedTurnSwiftUITests.swift +++ b/Packages/Tests/MessageListTests/MessageListPinnedTurnSwiftUITests.swift @@ -7,8 +7,8 @@ import ViewInspector @MainActor @Suite("MessageList pinned turn SwiftUI behavior") struct MessageListPinnedTurnSwiftUITests { - @Test("Pinned user message releases when streaming content fills the reserved space") - func pinnedUserMessageReleasesWhenStreamingContentFillsReservedSpace() async throws { + @Test("Streaming content that fills the reserved space keeps the list following the bottom") + func streamingContentFillingReservedSpaceFollowsBottom() async throws { let model = MessageListPinnedTurnModel() let view = MessageListPinnedTurnHarness(model: model) @@ -19,23 +19,27 @@ struct MessageListPinnedTurnSwiftUITests { ) defer { ViewHosting.expel(function: #function) } + // A fresh user message pins to the top with reserved space below it. model.messages = [ .init(text: "user", isUserMessage: true, height: 44), ] try await Task.sleep(for: .milliseconds(450)) - model.shouldObserveRelease = true + + // The streaming response grows the turn until it outgrows the viewport, + // collapsing the reserved space. The pin releases and the list must keep + // following the bottom — it must not be stranded above the bottom. model.messages.append(contentsOf: [ .init(text: "assistant 1", isUserMessage: false, height: 88), .init(text: "assistant 2", isUserMessage: false, height: 88), .init(text: "assistant 3", isUserMessage: false, height: 88), ]) - try await waitUntil(timeout: .seconds(2)) { - model.observedBottomRelease - } + // Let the layout settle after the turn fills the viewport, then assert the + // list reports it is following the bottom rather than stranded. + try await Task.sleep(for: .milliseconds(600)) - #expect(model.observedBottomRelease) + #expect(model.isAtBottom) } } @@ -43,15 +47,6 @@ struct MessageListPinnedTurnSwiftUITests { private final class MessageListPinnedTurnModel: ObservableObject { @Published var messages: [MessageListPinnedTurnMessage] = [] @Published var isAtBottom = false - var shouldObserveRelease = false - var observedBottomRelease = false - - func updateIsAtBottom(_ value: Bool) { - isAtBottom = value - if shouldObserveRelease, value { - observedBottomRelease = true - } - } } private struct MessageListPinnedTurnHarness: View { @@ -63,7 +58,7 @@ private struct MessageListPinnedTurnHarness: View { isStreaming: true, isAtBottom: Binding( get: { model.isAtBottom }, - set: { model.updateIsAtBottom($0) } + set: { model.isAtBottom = $0 } ) ) { message in Text(message.text) @@ -78,19 +73,4 @@ private struct MessageListPinnedTurnMessage: MessageListItem { let isUserMessage: Bool let height: CGFloat } - -private func waitUntil( - timeout: Duration, - interval: Duration = .milliseconds(20), - condition: @MainActor @escaping () -> Bool -) async throws { - let start = ContinuousClock.now - while !(await condition()) { - if ContinuousClock.now - start >= timeout { - Issue.record("Timed out waiting for condition") - return - } - try await Task.sleep(for: interval) - } -} #endif