Skip to content

Commit 36e6fb1

Browse files
sirily11claude
andcommitted
feat(code-review): add manual code review menus across briefing, project, and thread
Add a manual "Code Review" action that spawns a [Code Review] thread, mirroring the built-in Code Review hook but user-triggered. Two host functions in AppState+CodeReview: branch-level review grounds the reviewer in the branch briefing + thread summaries; thread-level review nests under a thread and reviews its changed files. Surfaces: - macOS: briefing card menu, project sidebar menu ("Code Review for Current Branch"), and per-thread row context menu. - iOS/Android: briefing detail + project menus (branch) and per-thread menus. Mobile relays via two new desktop-mediated autopilot ops (projectCreateCodeReview / threadCreateCodeReview) with AutopilotThreadBody and AutopilotCodeReviewResult; the Mac runs the review and returns the new thread id so the phone can navigate to it. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent e852661 commit 36e6fb1

16 files changed

Lines changed: 900 additions & 44 deletions

File tree

Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ public enum AutopilotOp: String, Codable, Sendable {
114114
case projectSecretsDownload
115115
case projectSecretsWrite
116116
case projectCreatePullRequest
117+
// Code review (desktop-mediated): spawn a `[Code Review]` thread on the Mac.
118+
// `projectCreateCodeReview` reviews a whole branch grounded in its briefing;
119+
// `threadCreateCodeReview` reviews a single thread's changes (the manual
120+
// equivalent of the built-in Code Review hook).
121+
case projectCreateCodeReview
122+
case threadCreateCodeReview
117123

118124
// Global search — one call returns on-device thread matches AND published
119125
// docs matches for the same query, so mobile gets a single combined result
@@ -500,6 +506,21 @@ public struct AutopilotPullRequestResult: Codable, Sendable {
500506
public init(url: String) { self.url = url }
501507
}
502508

509+
/// Addresses a single thread by id. Used by `threadCreateCodeReview`, where the
510+
/// desktop spawns a `[Code Review]` thread reviewing that thread's changes
511+
/// (the manual equivalent of the built-in Code Review hook).
512+
public struct AutopilotThreadBody: Codable, Sendable {
513+
public let sessionId: String
514+
public init(sessionId: String) { self.sessionId = sessionId }
515+
}
516+
517+
/// Result of `projectCreateCodeReview` / `threadCreateCodeReview`: the id of the
518+
/// spawned `[Code Review]` thread, so the phone can navigate to it once it syncs.
519+
public struct AutopilotCodeReviewResult: Codable, Sendable {
520+
public let threadId: String
521+
public init(threadId: String) { self.threadId = threadId }
522+
}
523+
503524
/// Per-project autopilot state powering the mobile context menu. Mirrors the
504525
/// desktop's `projectHasSecrets` / `projectHasDocs` / `projectHasReleaseWorkflow`
505526
/// checks so the phone can pick the same menu items (Download vs Set Up, etc.).
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import Foundation
2+
import RxCodeCore
3+
4+
/// Errors surfaced while starting a manual code review from a briefing card,
5+
/// project menu, or thread row.
6+
enum CodeReviewError: LocalizedError {
7+
case unknownThread
8+
case unknownProject
9+
case sendFailed(String)
10+
11+
var errorDescription: String? {
12+
switch self {
13+
case .unknownThread:
14+
return "Couldn't find the thread to review."
15+
case .unknownProject:
16+
return "Couldn't find the project to review."
17+
case .sendFailed(let message):
18+
return "Couldn't start the code review.\n\n\(message)"
19+
}
20+
}
21+
}
22+
23+
extension AppState {
24+
25+
/// Label stamped on manually-started review threads (matches the built-in
26+
/// Code Review hook so they show the same `[Code Review]` chip and nest in
27+
/// the sidebar review UI).
28+
static let manualCodeReviewLabel = "Code Review"
29+
30+
// MARK: - Branch-level review
31+
32+
/// Start a `[Code Review]` thread that reviews *all* the changes on `branch`,
33+
/// grounded in the branch briefing and the summaries of every thread that ran
34+
/// on it. The reviewer inspects the branch diff itself (it runs in `.auto`
35+
/// mode), so no diff needs to be computed here. Returns the new thread id.
36+
@discardableResult
37+
func createCodeReviewForBranch(project: Project, branch: String) async throws -> String {
38+
let briefing = threadStore.allBranchBriefingItems()
39+
.first(where: { $0.projectId == project.id && $0.branch == branch })?
40+
.briefing ?? ""
41+
let summaries = threadStore.allThreadSummaryItems()
42+
.filter { $0.projectId == project.id && $0.branch == branch }
43+
.sorted { $0.updatedAt > $1.updatedAt }
44+
let prompt = Self.branchCodeReviewPrompt(branch: branch, briefing: briefing, summaries: summaries)
45+
return try await startCodeReviewThread(projectId: project.id, parentThreadId: nil, prompt: prompt)
46+
}
47+
48+
// MARK: - Thread-level review
49+
50+
/// Start a `[Code Review]` thread nested under `sessionId` that reviews the
51+
/// files that thread changed — the manual equivalent of the built-in Code
52+
/// Review hook. Returns the new thread id.
53+
@discardableResult
54+
func createCodeReviewForThread(sessionId: String) async throws -> String {
55+
guard let summary = allSessionSummaries.first(where: { $0.id == sessionId })
56+
?? threadStore.fetch(id: sessionId)?.toSummary() else {
57+
throw CodeReviewError.unknownThread
58+
}
59+
guard let project = projects.first(where: { $0.id == summary.projectId }) else {
60+
throw CodeReviewError.unknownProject
61+
}
62+
63+
// Files this thread touched, de-duplicated in first-seen order.
64+
var seen = Set<String>()
65+
var changedFiles: [String] = []
66+
for edit in threadStore.fetchFileEdits(sessionId: sessionId) where seen.insert(edit.path).inserted {
67+
changedFiles.append(edit.path)
68+
}
69+
70+
// Pull the task/response from in-memory state when the thread is loaded;
71+
// fall back to the thread title (always available) for an idle thread
72+
// whose messages aren't currently in memory.
73+
let messages = stateForSession(sessionId).messages
74+
let task = messages.first(where: {
75+
$0.role == .user && !$0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
76+
})?.content ?? summary.title
77+
let finalResponse = lastAssistantResponseText(in: messages)
78+
79+
let prompt = Self.threadCodeReviewPrompt(
80+
task: task,
81+
changedFiles: changedFiles,
82+
finalResponse: finalResponse
83+
)
84+
return try await startCodeReviewThread(projectId: project.id, parentThreadId: sessionId, prompt: prompt)
85+
}
86+
87+
// MARK: - Shared launch
88+
89+
/// Spawn the review thread through the normal send pipeline. Runs in `.auto`
90+
/// mode (so the reviewer can read files / run `git diff` without per-tool
91+
/// prompts) with hooks skipped (the review thread shouldn't trigger its own
92+
/// review). Fire-and-forget — returns as soon as the thread id is known so
93+
/// the caller can navigate to it while the review runs.
94+
private func startCodeReviewThread(
95+
projectId: UUID,
96+
parentThreadId: String?,
97+
prompt: String
98+
) async throws -> String {
99+
let result = try await sendCrossProject(
100+
projectId: projectId,
101+
threadId: nil,
102+
prompt: prompt,
103+
permissionMode: .auto,
104+
waitForResponse: false,
105+
timeoutSeconds: 600,
106+
parentThreadId: parentThreadId,
107+
threadLabel: Self.manualCodeReviewLabel,
108+
skipHooks: true
109+
)
110+
if let error = result.error { throw CodeReviewError.sendFailed(error) }
111+
return result.threadId
112+
}
113+
114+
// MARK: - Prompts
115+
116+
private static let reviewMarker = "REVIEW_RESULT:"
117+
118+
static func branchCodeReviewPrompt(
119+
branch: String,
120+
briefing: String,
121+
summaries: [ThreadSummaryItem]
122+
) -> String {
123+
let trimmedBriefing = briefing.trimmingCharacters(in: .whitespacesAndNewlines)
124+
let briefingSection = trimmedBriefing.isEmpty ? "(no briefing recorded)" : trimmedBriefing
125+
let threadList: String
126+
if summaries.isEmpty {
127+
threadList = "(no thread summaries recorded)"
128+
} else {
129+
threadList = summaries.map { item in
130+
let title = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
131+
let summary = item.summary
132+
.trimmingCharacters(in: .whitespacesAndNewlines)
133+
.split(separator: "\n").first.map(String.init) ?? ""
134+
return summary.isEmpty ? "- \(title)" : "- \(title)\(summary)"
135+
}.joined(separator: "\n")
136+
}
137+
138+
return """
139+
You are reviewing all the code changes on branch `\(branch)` in this repository. Do not edit any files — only review.
140+
141+
## What this branch set out to do (briefing)
142+
\(briefingSection)
143+
144+
## Threads that ran on this branch
145+
\(threadList)
146+
147+
## What to do
148+
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`.
149+
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.
150+
3. List the specific, actionable issues you find (file + line where possible).
151+
152+
End your reply with a single line — exactly one of:
153+
`\(reviewMarker) PASS` (the changes look good as-is)
154+
`\(reviewMarker) FAIL` (changes are needed)
155+
"""
156+
}
157+
158+
static func threadCodeReviewPrompt(
159+
task: String,
160+
changedFiles: [String],
161+
finalResponse: String
162+
) -> String {
163+
let fileList = changedFiles.isEmpty
164+
? "(no recorded file edits — inspect the working tree / recent commits for what changed)"
165+
: changedFiles.map { "- \($0)" }.joined(separator: "\n")
166+
let response = finalResponse.trimmingCharacters(in: .whitespacesAndNewlines)
167+
let responseSection = response.isEmpty ? "(no final response recorded)" : response
168+
169+
return """
170+
You are reviewing another agent's code change in this repository. Do not edit any files — only review.
171+
172+
## The user's task
173+
\(task)
174+
175+
## Files the agent changed
176+
\(fileList)
177+
178+
## The agent's final response
179+
\(responseSection)
180+
181+
## What to do
182+
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.
183+
184+
End your reply with a single line — exactly one of:
185+
`\(reviewMarker) PASS` (the change is good as-is)
186+
`\(reviewMarker) FAIL` (changes are needed)
187+
188+
If you FAIL the review, list the specific, actionable issues to fix above that line.
189+
"""
190+
}
191+
}

RxCode/App/AppState+MobileAutopilot.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,24 @@ extension AppState {
338338
let url = try await createPullRequestForBranch(project: project, branch: body.branch)
339339
return try encoder.encode(AutopilotPullRequestResult(url: url.absoluteString))
340340

341+
case .projectCreateCodeReview:
342+
// Same as the desktop briefing/project "Code Review" action: spawn a
343+
// `[Code Review]` thread reviewing the whole branch, grounded in its
344+
// briefing. Returns the new thread id so the phone can navigate to it.
345+
let body = try decodeAutopilotBody(request, as: AutopilotProjectBranchBody.self)
346+
guard let project = projects.first(where: { $0.id == body.projectId }) else {
347+
throw MobileRemoteConfigError.invalidRequest("No project found for the requested id.")
348+
}
349+
let threadId = try await createCodeReviewForBranch(project: project, branch: body.branch)
350+
return try encoder.encode(AutopilotCodeReviewResult(threadId: threadId))
351+
352+
case .threadCreateCodeReview:
353+
// Manual equivalent of the built-in Code Review hook for a single
354+
// thread: spawn a `[Code Review]` thread nested under it.
355+
let body = try decodeAutopilotBody(request, as: AutopilotThreadBody.self)
356+
let threadId = try await createCodeReviewForThread(sessionId: body.sessionId)
357+
return try encoder.encode(AutopilotCodeReviewResult(threadId: threadId))
358+
341359
case .projectSecretsDownload:
342360
let body = try decodeAutopilotBody(request, as: AutopilotProjectSecretsDownloadBody.self)
343361
guard let project = projects.first(where: { $0.id == body.projectId }) else {

RxCode/Views/Sidebar/BriefingView.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,19 @@ struct BriefingView: View {
627627
&& appState.ciStatusByProject[group.projectId]?.prNumber != nil
628628
}
629629

630+
/// Start a `[Code Review]` thread reviewing the whole branch (grounded in
631+
/// its briefing) and open it, mirroring the project/thread review menus.
632+
private func startCodeReview(for group: BriefingGroup, project: Project) {
633+
Task {
634+
if windowState.selectedProject?.id != project.id {
635+
appState.selectProject(project, in: windowState)
636+
}
637+
if let threadId = try? await appState.createCodeReviewForBranch(project: project, branch: group.branch) {
638+
appState.selectSession(id: threadId, in: windowState)
639+
}
640+
}
641+
}
642+
630643
private func cardMenu(for group: BriefingGroup, project: Project) -> some View {
631644
Menu {
632645
Button {
@@ -646,6 +659,14 @@ struct BriefingView: View {
646659
Label("Open Project", systemImage: "folder")
647660
}
648661

662+
Divider()
663+
664+
Button {
665+
startCodeReview(for: group, project: project)
666+
} label: {
667+
Label("Code Review for \(group.branch)", systemImage: "checklist")
668+
}
669+
649670
let hookItems = appState.projectContextMenuItems(for: project)
650671
if !hookItems.isEmpty {
651672
Divider()

0 commit comments

Comments
 (0)