diff --git a/Packages/Sources/MessageList/MessageList.swift b/Packages/Sources/MessageList/MessageList.swift index 00428cb..3e55921 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 af26c69..91ba467 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/Sources/RxCodeChatKit/ChatMessageBubble.swift b/Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift index 24b6191..9e900ed 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 7391528..957f636 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 c76bb37..fa78fa4 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/RxCodeChatKit/PlanLogic.swift b/Packages/Sources/RxCodeChatKit/PlanLogic.swift index 885d5e5..b111ddc 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/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift index adfe5ff..229cb2b 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. @@ -42,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. @@ -60,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/Hooks/HookPayloads.swift b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift index 8f2bbd0..b73294a 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/HookProfile.swift b/Packages/Sources/RxCodeCore/Models/HookProfile.swift index 8d831a8..1bd6121 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/Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift b/Packages/Sources/RxCodeCore/Models/HookStatusRecord.swift index d1c4dd0..24fbab7 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/Packages/Sources/RxCodeMarkdown/MarkdownView.swift b/Packages/Sources/RxCodeMarkdown/MarkdownView.swift index 0bfcc14..d33f07f 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) diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift index 0b22c69..b0f1409 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -114,6 +114,16 @@ 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 + // 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 @@ -500,6 +510,22 @@ 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 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 } +} + /// Per-project autopilot state powering the mobile context menu. Mirrors the /// desktop's `projectHasSecrets` / `projectHasDocs` / `projectHasReleaseWorkflow` /// checks so the phone can pick the same menu items (Download vs Set Up, etc.). diff --git a/Packages/Tests/MessageListTests/MessageListPinnedTurnSwiftUITests.swift b/Packages/Tests/MessageListTests/MessageListPinnedTurnSwiftUITests.swift index d7bee79..77f8502 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 diff --git a/Packages/Tests/MessageListTests/MessageListScrollAnchorTests.swift b/Packages/Tests/MessageListTests/MessageListScrollAnchorTests.swift index 5c05032..72f3ce0 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) + } } diff --git a/RxCode/App/AppState+CodeReview.swift b/RxCode/App/AppState+CodeReview.swift new file mode 100644 index 0000000..69861b2 --- /dev/null +++ b/RxCode/App/AppState+CodeReview.swift @@ -0,0 +1,201 @@ +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" + + /// 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`, + /// 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+Commit.swift b/RxCode/App/AppState+Commit.swift new file mode 100644 index 0000000..73c368d --- /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+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index 9f82b51..8f5c591 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+CrossProjectSend.swift b/RxCode/App/AppState+CrossProjectSend.swift index 15f635c..29cd64f 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+Helpers.swift b/RxCode/App/AppState+Helpers.swift index a642e7f..7e15cad 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 { diff --git a/RxCode/App/AppState+Hooks.swift b/RxCode/App/AppState+Hooks.swift index c3424de..e3c3c83 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+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift index 32e4a16..f87d61d 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+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift index 5eb0fb0..fb3980e 100644 --- a/RxCode/App/AppState+MobileAutopilot.swift +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -338,6 +338,40 @@ 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 .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/App/AppState+MobileSnapshots.swift b/RxCode/App/AppState+MobileSnapshots.swift index 328cd32..eea3a99 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 = codeReviewThreadIds return threadStore.allThreadSummaryItems() - .filter { knownProjectIds.contains($0.projectId) } + .filter { knownProjectIds.contains($0.projectId) && !reviewIds.contains($0.sessionId) } .map { MobileThreadSummary( sessionId: $0.sessionId, @@ -750,7 +752,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 { diff --git a/RxCode/App/AppState+SessionLifecycle.swift b/RxCode/App/AppState+SessionLifecycle.swift index ae7b100..2ccc886 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/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 1fbb587..5b1979a 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" : { @@ -3363,6 +3390,12 @@ } } } + }, + "Commit All Changes" : { + + }, + "Commit Files" : { + }, "Commit message" : { "localizations" : { @@ -3379,6 +3412,9 @@ } } } + }, + "Commit when a session finishes" : { + }, "Configuration" : { "localizations" : { @@ -5900,6 +5936,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 +6513,9 @@ } } } + }, + "Got it" : { + }, "Headers" : { "localizations" : { @@ -6490,6 +6532,9 @@ } } } + }, + "Hide code reviews" : { + }, "Hide details" : { "localizations" : { @@ -8565,6 +8610,9 @@ } } } + }, + "New in RxCode" : { + }, "New Template" : { "localizations" : { @@ -9726,9 +9774,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 +10554,9 @@ } } } + }, + "Pick which model performs the review — defaults to the same model as the thread." : { + }, "Pin" : { "localizations" : { @@ -10908,6 +10956,9 @@ } } } + }, + "Push to the remote automatically, or keep the commit local." : { + }, "Quit" : { "localizations" : { @@ -12044,6 +12095,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 +12805,9 @@ } } } + }, + "See the latest features and updates" : { + }, "Select a dispatchable release workflow first (re-add the repo to rescan if needed)." : { "localizations" : { @@ -13306,6 +13363,9 @@ } } } + }, + "Show %lld code review(s)" : { + }, "Show active chats" : { "localizations" : { @@ -14281,6 +14341,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 +14779,9 @@ } } } + }, + "Triggers automatically once an agent session finishes." : { + }, "Type" : { "localizations" : { @@ -15336,6 +15402,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 5149cb6..8270c55 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 ) } @@ -88,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] { @@ -146,6 +196,7 @@ final class AppStateHookController: HookController { projectId: UUID, parentThreadId: String, label: String, + agentProvider: AgentProvider?, model: String?, prompt: String, timeoutSeconds: TimeInterval @@ -156,10 +207,13 @@ final class AppStateHookController: HookController { projectId: projectId, threadId: nil, prompt: prompt, + agentProvider: agentProvider, 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/CodeReviewHook.swift b/RxCode/Services/Hooks/hooks/CodeReviewHook.swift index b839828..b5324ca 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 @@ -52,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, @@ -70,13 +80,25 @@ 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( projectId: payload.project.id, parentThreadId: payload.sessionId, label: "Code Review", - model: resolvedModel, + agentProvider: selection?.provider, + model: selection?.model, prompt: prompt, timeoutSeconds: Self.reviewTimeout ) @@ -108,7 +130,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 +157,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 +192,8 @@ final class CodeReviewHook: Hook { name: hook.name, trigger: hook.trigger.displayName, output: result, - isError: isError + isError: isError, + isComplete: true ) } diff --git a/RxCode/Services/Hooks/hooks/CommitPushHook.swift b/RxCode/Services/Hooks/hooks/CommitPushHook.swift index bbd8c8f..ba178e5 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 @@ -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,8 +30,17 @@ 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 + // 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) @@ -81,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/Services/Hooks/hooks/UserAddedHook.swift b/RxCode/Services/Hooks/hooks/UserAddedHook.swift index 0b285cd..f9c4141 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 0000000..a01f217 --- /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 0000000..cf0da67 --- /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 ac2ad9e..407f5ec 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 @@ -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) @@ -565,7 +569,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 +578,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 +587,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( @@ -784,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/Chat/RecentChatsSuggestionList.swift b/RxCode/Views/Chat/RecentChatsSuggestionList.swift index cf99c01..f940312 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/Hooks/HookProfileDetailForm.swift b/RxCode/Views/Hooks/HookProfileDetailForm.swift index b91f939..dede168 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 ?? "" }, diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index f1e23d2..a22f0ae 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 @@ -503,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/SettingsView.swift b/RxCode/Views/SettingsView.swift index bfa2d12..b32aa2f 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/BriefingThreadRow.swift b/RxCode/Views/Sidebar/BriefingThreadRow.swift index be821b7..d61acb9 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 f21a4df..72f2c2b 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.codeReviewThreadIds + 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 { @@ -627,6 +634,31 @@ 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) + } + } + } + + /// 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 { @@ -646,6 +678,20 @@ struct BriefingView: View { Label("Open Project", systemImage: "folder") } + Divider() + + Button { + startCodeReview(for: group, project: project) + } label: { + Label("Code Review for \(group.branch)", systemImage: "checklist") + } + + Button { + startCommitAll(for: project) + } label: { + Label("Commit All Changes", systemImage: "checkmark.circle") + } + let hookItems = appState.projectContextMenuItems(for: project) if !hookItems.isEmpty { Divider() @@ -818,7 +864,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) } diff --git a/RxCode/Views/Sidebar/HistoryListView.swift b/RxCode/Views/Sidebar/HistoryListView.swift index f71baf4..acf0b64 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 45dc2f1..f416321 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 @@ -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,20 @@ struct ProjectChatRow: View { let onTogglePin: () -> Void 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 + /// 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 + /// 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 @@ -97,6 +121,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 +129,10 @@ struct ProjectChatRow: View { var body: some View { HStack(spacing: 8) { + if let disclosure = reviewDisclosure { + reviewDisclosureControl(disclosure) + } + if isActiveStatus { statusIndicator } @@ -114,7 +143,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) @@ -126,6 +155,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))) @@ -143,7 +176,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 +212,13 @@ struct ProjectChatRow: View { Label("Archive", systemImage: "archivebox") } } + Divider() + Button { onCodeReview() } label: { + Label("Code Review for this thread", systemImage: "checklist") + } + Button { onCommitFiles() } label: { + Label("Commit Files", systemImage: "checkmark.circle") + } if !hookMenuItems.isEmpty { Divider() HookContextMenuItems(items: hookMenuItems) @@ -200,6 +240,47 @@ 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 + 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/ProjectListView.swift b/RxCode/Views/Sidebar/ProjectListView.swift index c6e6c5b..d39b426 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 63fbc19..ee6bbfe 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,16 @@ struct ProjectTreeView: View { appState.selectProject(project, in: windowState) appState.startNewChat(in: windowState) }, + 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) ) @@ -305,6 +327,8 @@ private struct ProjectTreeRow: View { let onRename: () -> Void let onDelete: () -> Void let onNewChat: () -> Void + let onCodeReview: () -> Void + let onCommitAll: () -> Void let hookMenuItems: [HookMenuItem] @State private var isHovered = false @@ -450,6 +474,12 @@ private struct ProjectTreeRow: View { Button { onOpenInNewWindow() } label: { Label("Open in New Window", systemImage: "macwindow.badge.plus") } + Button { onCodeReview() } label: { + Label("Code Review for Current Branch", systemImage: "checklist") + } + Button { onCommitAll() } label: { + Label("Commit All Changes", systemImage: "checkmark.circle") + } if canCreatePR { Button { startCreatePR() } label: { Label(creatingPR ? "Creating Pull Request…" : "Create Pull Request", @@ -506,8 +536,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 +549,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 +603,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 +618,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 +705,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 +739,26 @@ 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) + } + } + }, + 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, + showLabelChip: showLabelChip, + reviewDisclosure: reviewDisclosure, + reviewPassed: appState.reviewPassedBySession[sessionId] ) } } 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 205b044..c9cd0a3 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 thread-spawning project actions such as code review and commit. */ +@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 b0fed8f..22765a9 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,12 @@ 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"), + PROJECT_COMMIT_ALL("projectCommitAll"), + THREAD_COMMIT_FILES("threadCommitFiles"), + // Global search — one call returns thread matches AND published-docs matches. SEARCH_THREADS_AND_DOCS("searchThreadsAndDocs"), } @@ -228,13 +234,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 8bb58b7..60101f7 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,50 @@ 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 + + /** 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 6f0e1c8..38c0171 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,22 @@ 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) + + /** 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 d034c20..038f034 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,10 +7,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.MergeType import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.Book +import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.HourglassEmpty import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.RateReview import androidx.compose.material.icons.outlined.Sell import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator @@ -85,12 +87,18 @@ 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 isCommittingAll by remember { mutableStateOf(false) } var info by remember { mutableStateOf(null) } // Only repo-backed projects have autopilot state to load. @@ -121,12 +129,54 @@ 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 + } + } + } + + 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( @@ -180,6 +230,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 +313,34 @@ 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.") + } + }, + ) + } + + 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 5c2c55d..97092fe 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 7509c62..34e4029 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 @@ -45,6 +46,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 +143,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 +283,34 @@ fun ChatScreen( }, leadingIcon = { Icon(Icons.Outlined.Difference, contentDescription = null) }, ) + androidx.compose.material3.DropdownMenuItem( + text = { Text("Code Review") }, + onClick = { + menuExpanded = false + menuScope.launch { + runCatching { + val threadId = viewModel.requestThreadCreateCodeReview(resolvedId) + viewModel.requestSnapshot("code_review_started") + viewModel.selectSession(threadId) + } + } + }, + leadingIcon = { Icon(Icons.Outlined.RateReview, contentDescription = null) }, + ) + androidx.compose.material3.DropdownMenuItem( + text = { Text("Commit Files") }, + onClick = { + menuExpanded = false + menuScope.launch { + runCatching { + val threadId = viewModel.requestThreadCommitFiles(resolvedId) + viewModel.requestSnapshot("commit_started") + viewModel.selectSession(threadId) + } + } + }, + leadingIcon = { Icon(Icons.Outlined.CheckCircle, contentDescription = null) }, + ) 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 d528770..09fbc0f 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,28 @@ fun SessionsScreen( val scope = rememberCoroutineScope() var refreshing by remember { mutableStateOf(false) } + // Start a `[Code Review]` thread on the Mac for one thread's changes (the + // manual equivalent of the built-in Code Review hook), then navigate to it. + fun startThreadReview(sessionId: String) { + scope.launch { + runCatching { + val threadId = viewModel.requestThreadCreateCodeReview(sessionId) + viewModel.requestSnapshot("code_review_started") + onNewThread(threadId) + } + } + } + + fun startThreadCommit(sessionId: String) { + scope.launch { + runCatching { + val threadId = viewModel.requestThreadCommitFiles(sessionId) + viewModel.requestSnapshot("commit_started") + onNewThread(threadId) + } + } + } + Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { @@ -170,21 +211,64 @@ 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) }, + onCommitFiles = { startThreadCommit(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) }, + onCommitFiles = { startThreadCommit(child.id) }, + indentLevel = 1, + titleOverride = "Review ${index + 1}", + showLabel = false, + ) + } + } } } } @@ -287,10 +371,21 @@ private fun SessionCard( onRename: () -> Unit, onArchive: () -> Unit, onDelete: () -> Unit, + onCodeReview: () -> Unit = {}, + onCommitFiles: () -> 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 +400,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 +469,16 @@ private fun SessionCard( Icon(Icons.Outlined.MoreVert, contentDescription = "More") } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Code Review") }, + leadingIcon = { Icon(Icons.Outlined.RateReview, contentDescription = null) }, + onClick = { menuOpen = false; onCodeReview() }, + ) + DropdownMenuItem( + text = { Text("Commit Files") }, + leadingIcon = { Icon(Icons.Outlined.CheckCircle, contentDescription = null) }, + onClick = { menuOpen = false; onCommitFiles() }, + ) DropdownMenuItem( text = { Text("Rename") }, leadingIcon = { Icon(Icons.Outlined.DriveFileRenameOutline, contentDescription = null) }, diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index 8d0d0b7..a3e5aa4 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/State/MobileAppState+Autopilot.swift b/RxCodeMobile/State/MobileAppState+Autopilot.swift index 9fce189..cea75b8 100644 --- a/RxCodeMobile/State/MobileAppState+Autopilot.swift +++ b/RxCodeMobile/State/MobileAppState+Autopilot.swift @@ -448,6 +448,44 @@ 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 + } + + /// 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 4b9acff..2720740 100644 --- a/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift +++ b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift @@ -51,16 +51,27 @@ 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 = {} + /// 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) createPRItem() + codeReviewItem() } else { Button {} label: { Label("Loading Autopilot…", systemImage: "hourglass") @@ -69,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 { @@ -140,6 +160,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/GlassThreadCard.swift b/RxCodeMobile/Views/GlassThreadCard.swift index c1551bf..1220210 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/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index 8c2990b..b5db391 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -13,6 +13,8 @@ struct MobileBriefingDetailView: View { @State private var showingNewThread = false @State private var isInitializingGit = false @State private var isCreatingPR = false + @State private var isCreatingReview = false + @State private var isCommittingProject = false // Autopilot context menu (1:1 with the desktop briefing/project menu). @State private var autopilotStatus: AutopilotProjectStatus? @@ -52,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, @@ -66,7 +68,11 @@ 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) }, + isCommittingAll: isCommittingProject, + onCommitAll: { commitProjectChanges(project: project) } ) if gitHubURL != nil { Divider() } } @@ -120,6 +126,16 @@ 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." + ) + .mobileAutopilotLoadingDialog( + isCommittingProject, + title: "Committing Changes…", + message: "The Mac is starting a commit thread for this project." + ) } private var group: GroupedBriefing? { @@ -144,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 @@ -205,6 +221,44 @@ 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) + } + } + } + + /// 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 31515f1..ff2b566 100644 --- a/RxCodeMobile/Views/MobileChatView+Toolbar.swift +++ b/RxCodeMobile/Views/MobileChatView+Toolbar.swift @@ -29,6 +29,16 @@ extension MobileChatView { } label: { Label("View Changes", systemImage: "plus.forwardslash.minus") } + Button { + startCodeReview() + } label: { + Label("Code Review", systemImage: "checklist") + } + Button { + startCommitFiles() + } label: { + Label("Commit Files", systemImage: "checkmark.circle") + } Divider() Button { showingRenameSheet = true @@ -105,6 +115,34 @@ 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) + } + } + } + + /// 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 6aa0e46..b3dc070 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -32,6 +32,8 @@ struct SessionsList: View { @State private var showingDeleteProjectConfirm = false @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 @@ -61,6 +63,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 +140,16 @@ 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." + ) + .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 @@ -157,6 +172,70 @@ 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) + } + } + } + + /// 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 @@ -164,26 +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) - } + 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: { @@ -217,23 +303,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 +317,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 +348,18 @@ struct SessionsList: View { @ViewBuilder private func threadContextMenu(for session: SessionSummary) -> some View { + Button { + createThreadCodeReview(sessionID: session.id) + } label: { + Label("Code Review", systemImage: "checklist") + } + + Button { + commitThreadFiles(sessionID: session.id) + } label: { + Label("Commit Files", systemImage: "checkmark.circle") + } + Button { Task { await state.archiveThread(sessionID: session.id) } } label: { @@ -329,9 +416,88 @@ 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. 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) + + threadCard(for: session, reviewChildren: children, isReviewExpanded: isExpanded) + + 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, + reviewChildren: [SessionSummary] = [], + isReviewExpanded: Bool = false + ) -> some View { + GlassThreadCard( + session: session, + isSelected: selected == session.id, + usesNavigationLink: !usesSelection, + onSelect: usesSelection ? { selected = session.id } : nil, + indentLevel: indentLevel, + titleOverride: titleOverride, + 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 { + if indentLevel == 0, session.id == visible.last?.id { loadMore() } + } + .contextMenu { + threadContextMenu(for: session) + } + } + + private func toggleReviews(for parentID: String) { + withAnimation(.easeInOut(duration: 0.2)) { + if expandedReviewParentIds.contains(parentID) { + expandedReviewParentIds.remove(parentID) + } else { + expandedReviewParentIds.insert(parentID) + } + } } private var usesDesktopSearch: Bool { @@ -347,8 +513,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] {