Skip to content

Commit b314854

Browse files
committed
feat: add manual commit actions
1 parent 412da78 commit b314854

26 files changed

Lines changed: 475 additions & 39 deletions

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ public enum AutopilotOp: String, Codable, Sendable {
120120
// equivalent of the built-in Code Review hook).
121121
case projectCreateCodeReview
122122
case threadCreateCodeReview
123+
// Manual commit actions. The desktop starts an agent turn: project commits
124+
// all uncommitted files, thread commits only that thread's recorded files.
125+
case projectCommitAll
126+
case threadCommitFiles
123127

124128
// Global search — one call returns on-device thread matches AND published
125129
// docs matches for the same query, so mobile gets a single combined result
@@ -514,8 +518,9 @@ public struct AutopilotThreadBody: Codable, Sendable {
514518
public init(sessionId: String) { self.sessionId = sessionId }
515519
}
516520

517-
/// Result of `projectCreateCodeReview` / `threadCreateCodeReview`: the id of the
518-
/// spawned `[Code Review]` thread, so the phone can navigate to it once it syncs.
521+
/// Result of thread-spawning project actions such as code review and commit:
522+
/// the id of the spawned or updated thread, so the phone can navigate to it once
523+
/// it syncs.
519524
public struct AutopilotCodeReviewResult: Codable, Sendable {
520525
public let threadId: String
521526
public init(threadId: String) { self.threadId = threadId }

RxCode/App/AppState+Commit.swift

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import Foundation
2+
import RxCodeCore
3+
4+
/// Errors surfaced while starting a manual commit turn from a project, briefing,
5+
/// or thread menu.
6+
enum CommitFilesError: 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 commit."
15+
case .unknownProject:
16+
return "Couldn't find the project to commit."
17+
case .sendFailed(let message):
18+
return "Couldn't start the commit.\n\n\(message)"
19+
}
20+
}
21+
}
22+
23+
extension AppState {
24+
static let manualCommitLabel = "Commit"
25+
26+
/// Send a commit-only follow-up into the selected thread. The prompt names
27+
/// the files recorded for that thread so the agent can avoid staging
28+
/// unrelated work.
29+
@discardableResult
30+
func commitFilesForThread(sessionId: String) async throws -> String {
31+
guard let summary = allSessionSummaries.first(where: { $0.id == sessionId })
32+
?? threadStore.fetch(id: sessionId)?.toSummary() else {
33+
throw CommitFilesError.unknownThread
34+
}
35+
guard projects.contains(where: { $0.id == summary.projectId }) else {
36+
throw CommitFilesError.unknownProject
37+
}
38+
39+
var seen = Set<String>()
40+
let changedFiles = threadStore.fetchFileEdits(sessionId: sessionId).compactMap { edit in
41+
seen.insert(edit.path).inserted ? edit.path : nil
42+
}
43+
44+
let result = try await sendCrossProject(
45+
projectId: nil,
46+
threadId: sessionId,
47+
prompt: Self.threadCommitPrompt(changedFiles: changedFiles),
48+
permissionMode: .auto,
49+
waitForResponse: false,
50+
timeoutSeconds: 600,
51+
setupKind: HookSetupKind.commitPush
52+
)
53+
if let error = result.error { throw CommitFilesError.sendFailed(error) }
54+
return result.threadId
55+
}
56+
57+
/// Start a commit-only thread for all current uncommitted project changes.
58+
/// Used by project rows and briefing cards.
59+
@discardableResult
60+
func commitAllChangesForProject(project: Project) async throws -> String {
61+
let result = try await sendCrossProject(
62+
projectId: project.id,
63+
threadId: nil,
64+
prompt: Self.projectCommitPrompt(projectName: project.name),
65+
permissionMode: .auto,
66+
waitForResponse: false,
67+
timeoutSeconds: 600,
68+
threadLabel: Self.manualCommitLabel,
69+
setupKind: HookSetupKind.commitPush
70+
)
71+
if let error = result.error { throw CommitFilesError.sendFailed(error) }
72+
return result.threadId
73+
}
74+
75+
static func threadCommitPrompt(changedFiles: [String]) -> String {
76+
let fileList = changedFiles.isEmpty
77+
? "(no recorded file edits — inspect this thread and the working tree, then commit only the files that belong to this thread)"
78+
: changedFiles.map { "- \($0)" }.joined(separator: "\n")
79+
80+
return """
81+
Commit the files changed by this thread.
82+
83+
Files changed by this thread:
84+
\(fileList)
85+
86+
Steps:
87+
1. Inspect `git status` and the relevant diffs.
88+
2. Stage only the files that belong to this thread. Do not stage unrelated project changes.
89+
3. Create a local commit with a clear Conventional Commit message.
90+
4. Report the commit hash and the files committed.
91+
92+
Do not push the commit unless the user explicitly asks for a push.
93+
Do not make further code changes beyond what is needed to commit these files.
94+
"""
95+
}
96+
97+
static func projectCommitPrompt(projectName: String) -> String {
98+
"""
99+
Commit all current uncommitted changes for project `\(projectName)`.
100+
101+
Steps:
102+
1. Inspect `git status` and the relevant diffs.
103+
2. Stage all modified, deleted, and untracked files that belong to the current project change set.
104+
3. Create a local commit with a clear Conventional Commit message.
105+
4. Report the commit hash and the files committed.
106+
107+
If there are no uncommitted changes, report that clearly and do not create an empty commit.
108+
Do not push the commit unless the user explicitly asks for a push.
109+
Do not make further code changes beyond what is needed to commit the current changes.
110+
"""
111+
}
112+
}

RxCode/App/AppState+CrossProjectSend.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ extension AppState {
4141
timeoutSeconds: TimeInterval = 120,
4242
parentThreadId: String? = nil,
4343
threadLabel: String? = nil,
44-
skipHooks: Bool = false
44+
skipHooks: Bool = false,
45+
setupKind: String? = nil
4546
) async throws -> CrossProjectSendResult {
4647
// Resolve target project + thread.
4748
let resolvedProject: Project
@@ -108,6 +109,9 @@ extension AppState {
108109
// id before returning so the caller's agent never sees `pending-...`
109110
// (which it can't use to follow up via `get_thread_messages` etc.).
110111
let postSendKey = window.currentSessionId ?? resolvedThreadId ?? ""
112+
if let setupKind {
113+
setupSessionKeys[setupKind, default: []].insert(postSendKey)
114+
}
111115
let resolvedThreadIdForReturn: String
112116
if postSendKey.hasPrefix("pending-") {
113117
// Cap the rename wait at the request's timeout so we still honor
@@ -121,6 +125,9 @@ extension AppState {
121125
} else {
122126
resolvedThreadIdForReturn = postSendKey
123127
}
128+
if let setupKind, resolvedThreadIdForReturn != postSendKey {
129+
setupSessionKeys[setupKind, default: []].insert(resolvedThreadIdForReturn)
130+
}
124131

125132
// Stamp linkage (parent thread / label / skip-hooks) onto the freshly
126133
// created thread now that its real id is known. Only for new threads —

RxCode/App/AppState+MobileAutopilot.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,22 @@ extension AppState {
356356
let threadId = try await createCodeReviewForThread(sessionId: body.sessionId)
357357
return try encoder.encode(AutopilotCodeReviewResult(threadId: threadId))
358358

359+
case .projectCommitAll:
360+
// Same as the desktop project/briefing "Commit All Changes" action:
361+
// start a commit-only thread for the current project worktree.
362+
let body = try decodeAutopilotBody(request, as: AutopilotProjectBody.self)
363+
guard let project = projects.first(where: { $0.id == body.projectId }) else {
364+
throw MobileRemoteConfigError.invalidRequest("No project found for the requested id.")
365+
}
366+
let threadId = try await commitAllChangesForProject(project: project)
367+
return try encoder.encode(AutopilotCodeReviewResult(threadId: threadId))
368+
369+
case .threadCommitFiles:
370+
// Commit only the files recorded for one thread.
371+
let body = try decodeAutopilotBody(request, as: AutopilotThreadBody.self)
372+
let threadId = try await commitFilesForThread(sessionId: body.sessionId)
373+
return try encoder.encode(AutopilotCodeReviewResult(threadId: threadId))
374+
359375
case .projectSecretsDownload:
360376
let body = try decodeAutopilotBody(request, as: AutopilotProjectSecretsDownloadBody.self)
361377
guard let project = projects.first(where: { $0.id == body.projectId }) else {

RxCode/Resources/Localizable.xcstrings

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3390,6 +3390,12 @@
33903390
}
33913391
}
33923392
}
3393+
},
3394+
"Commit All Changes" : {
3395+
3396+
},
3397+
"Commit Files" : {
3398+
33933399
},
33943400
"Commit message" : {
33953401
"localizations" : {

RxCode/Services/Hooks/hooks/CommitPushHook.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,6 @@ final class CommitPushHook: Hook {
2121
private let logger = Logger(subsystem: "com.claudework", category: "CommitPushHook")
2222

2323
func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome {
24-
let hooks = await controller.enabledHookProfiles(projectId: payload.project.id, trigger: .afterSessionStop)
25-
.filter { $0.action == .commitPush }
26-
guard let hook = hooks.first else { return .ignored }
27-
2824
// Loop guard FIRST, so the commit turn always consumes its marker even if
2925
// that turn errored — otherwise a stale marker would skip the next real
3026
// turn's commit.
@@ -34,6 +30,10 @@ final class CommitPushHook: Hook {
3430
return .ignored
3531
}
3632

33+
let hooks = await controller.enabledHookProfiles(projectId: payload.project.id, trigger: .afterSessionStop)
34+
.filter { $0.action == .commitPush }
35+
guard let hook = hooks.first else { return .ignored }
36+
3737
guard payload.reason == .completed, !payload.turnDidError else { return .ignored }
3838

3939
// Defer while the user still has queued messages — they'll run as further

RxCode/Views/Chat/RecentChatsSuggestionList.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ struct RecentChatsSuggestionList: View {
149149

150150
Divider()
151151

152+
Button {
153+
Task { _ = try? await appState.commitFilesForThread(sessionId: summary.id) }
154+
} label: {
155+
Label("Commit Files", systemImage: "checkmark.circle")
156+
}
157+
158+
Divider()
159+
152160
Button(role: .destructive) {
153161
sessionToDelete = chatSession
154162
} label: {

RxCode/Views/MainView.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,12 @@ struct ProjectTabButton: View {
506506
HookContextMenuItems(items: hookItems)
507507
Divider()
508508
}
509+
Button {
510+
Task { _ = try? await appState.commitAllChangesForProject(project: project) }
511+
} label: {
512+
Label("Commit All Changes", systemImage: "checkmark.circle")
513+
}
514+
Divider()
509515
Button {
510516
renameText = project.name
511517
projectToRename = project

RxCode/Views/Sidebar/BriefingView.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,18 @@ struct BriefingView: View {
647647
}
648648
}
649649

650+
/// Start a commit-only thread for all current project changes and open it.
651+
private func startCommitAll(for project: Project) {
652+
Task {
653+
if windowState.selectedProject?.id != project.id {
654+
appState.selectProject(project, in: windowState)
655+
}
656+
if let threadId = try? await appState.commitAllChangesForProject(project: project) {
657+
appState.selectSession(id: threadId, in: windowState)
658+
}
659+
}
660+
}
661+
650662
private func cardMenu(for group: BriefingGroup, project: Project) -> some View {
651663
Menu {
652664
Button {
@@ -674,6 +686,12 @@ struct BriefingView: View {
674686
Label("Code Review for \(group.branch)", systemImage: "checklist")
675687
}
676688

689+
Button {
690+
startCommitAll(for: project)
691+
} label: {
692+
Label("Commit All Changes", systemImage: "checkmark.circle")
693+
}
694+
677695
let hookItems = appState.projectContextMenuItems(for: project)
678696
if !hookItems.isEmpty {
679697
Divider()

RxCode/Views/Sidebar/HistoryListView.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,14 @@ struct HistoryListView: View {
224224

225225
Divider()
226226

227+
Button {
228+
Task { _ = try? await appState.commitFilesForThread(sessionId: summary.id) }
229+
} label: {
230+
Label("Commit Files", systemImage: "checkmark.circle")
231+
}
232+
233+
Divider()
234+
227235
Button(role: .destructive) {
228236
sessionToDelete = chatSession
229237
} label: {

0 commit comments

Comments
 (0)