From 2d3676c77d86e1a44dc57235b85c6ca6ef5ad120 Mon Sep 17 00:00:00 2001 From: Laurent Guitton Date: Mon, 17 Aug 2026 10:47:42 +0200 Subject: [PATCH 01/12] feat(commit-review): pipe findings to a CLI agent with optional scratch worktree Task 3 of v3.7.0: adds buildReviewFixPrompt (pure, no secrets, PTY-safe), a Fix with agent control in CommitReviewModal (tool picker + scratch worktree checkbox), and useCommitReview.armReReview/onStagedSetChanged, the real staged-set-watcher trigger for the one-shot re-review after a fix handoff (decision D7: the prompt is typed into the agent PTY without pressing Enter). Factors confirmNewAiTask's scratch-worktree sequence into a shared helper. Un-hides the previously dangling commitReviewAutoReReview setting now that it has a real consumer. disabled --- apps/desktop/src/App.vue | 97 +++++++++++++---- .../src/components/CommitReviewModal.vue | 103 +++++++++++++++++- apps/desktop/src/components/SettingsPanel.vue | 20 +++- .../__tests__/CommitReviewModal.test.ts | 52 +++++++++ .../__tests__/useCommitReview.test.ts | 101 +++++++++++++++++ .../src/composables/useCommitReview.ts | 71 ++++++++++-- apps/desktop/src/locales/en.ts | 24 ++++ apps/desktop/src/locales/es.ts | 21 ++++ apps/desktop/src/locales/fr.ts | 21 ++++ apps/desktop/src/locales/pt-BR.ts | 21 ++++ apps/desktop/src/locales/zh-CN.ts | 21 ++++ .../utils/__tests__/reviewFixPrompt.test.ts | 89 +++++++++++++++ apps/desktop/src/utils/reviewFixPrompt.ts | 58 ++++++++++ 13 files changed, 659 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/utils/__tests__/reviewFixPrompt.test.ts create mode 100644 apps/desktop/src/utils/reviewFixPrompt.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index d7ba20bd..2beab29d 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -98,6 +98,7 @@ import { useLaunchpadPoller } from "./composables/useLaunchpadPoller"; import { useSecretsScanner } from "./composables/useSecretsScanner"; import { useCommitReview } from "./composables/useCommitReview"; import { useCommitReviewNav } from "./composables/useCommitReviewNav"; +import { buildReviewFixPrompt } from "./utils/reviewFixPrompt"; import { resolveCommitReviewShortcut } from "./composables/commitReviewKeymap"; import { useLaunchpadPrs } from "./composables/useLaunchpadPrs"; import { diffLaunchpad, isBotAuthor, type LaunchpadEvent } from "./composables/useLaunchpadNotifications"; @@ -117,7 +118,7 @@ import { TOGGLE_GIT_TREE_KEY, OPEN_SETTINGS_KEY, } from "./composables/branchPickerBridge"; -import { gitStash, gitStashPop, gitStashList, openInEditor, setGitConfig, gitDiscard, gitAddToGitignore, gitDeleteBranch, gitDeleteTag, gitDeleteRemoteTag, gitRemoteInfo, gitUnpushedTags, gitPushTags, gitMergeBase, gitResetToCommit, gitCommitSubmoduleChanges, gitSubmoduleCheckUpdates, scratchWorktreeCreate, scratchWorktreeDiscard, scratchWorktreeMergeBack, gitWorktreeList, gitWorktreeRemove, type CommitSubmoduleChange } from "./utils/backend"; +import { gitStash, gitStashPop, gitStashList, openInEditor, setGitConfig, gitDiscard, gitAddToGitignore, gitDeleteBranch, gitDeleteTag, gitDeleteRemoteTag, gitRemoteInfo, gitUnpushedTags, gitPushTags, gitMergeBase, gitResetToCommit, gitCommitSubmoduleChanges, gitSubmoduleCheckUpdates, scratchWorktreeCreate, scratchWorktreeDiscard, scratchWorktreeMergeBack, gitWorktreeList, gitWorktreeRemove, type CommitSubmoduleChange, type ScratchWorktree } from "./utils/backend"; import { useCommitActions } from "./composables/useCommitActions"; const { t, locale } = useI18n(); @@ -1207,18 +1208,19 @@ const repoSidebarListeners = { // opened/switched. Never a setInterval — see apps/desktop/CLAUDE.md P6.4. watch( () => [repoFolderPath.value, repoStats.value.staged] as const, - ([cwd]) => { + ([cwd, staged]) => { if (cwd) { secretsScanner.scan(cwd, settings.value); } else { secretsScanner.findings.value = []; } // v3.7.0 — Commit Review: a staged-set/repo change invalidates whatever - // findings are on screen (the diff they reviewed no longer exists). - // Never auto-run here — the pass only runs on the explicit "Review - // staged changes" click (decision D5; the one-shot re-review after a - // "Fix with agent" handoff is Task 3, out of scope for this PR). - commitReview.reset(); + // findings are on screen (the diff they reviewed no longer exists — D5, + // no auto-run on a plain staging change). `onStagedSetChanged` is also + // the real trigger for Task 3's "re-review on the next staging change" + // after a "Fix with agent" handoff: it's a no-op unless `armReReview()` + // was called, in which case it fires exactly one re-review here. + commitReview.onStagedSetChanged(cwd ?? "", locale.value, staged); }, { immediate: true }, ); @@ -1802,28 +1804,43 @@ function onNewAiTask() { aiTaskNamePrompt.value = true; } +/** + * v3.7.0 (Task 3) — the scratch-worktree creation sequence shared by "New AI + * task" (`confirmNewAiTask`) and Commit Review's "Fix with agent -> in a + * scratch worktree": create -> register -> select -> open. Factored into one + * implementation so both callers stay in lockstep instead of drifting. + * Returns `null` (without throwing) when there's no active project tab to + * base the scratch on — callers decide how to surface that. + */ +async function createAiTaskScratchWorktree(name?: string): Promise { + if (!repoFolderPath.value || activeTabId.value === null) return null; + // Always base the scratch on the active project's root, not whatever + // worktree is currently selected, so AI tasks branch from the project. + const projectTab = repoTabs.value.find((t) => t.id === activeTabId.value); + const origin = projectTab?.path ?? repoFolderPath.value; + const scratch = await scratchWorktreeCreate(origin, undefined, name || undefined); + aiTasks.register({ + path: scratch.path, + originCwd: origin, + branch: scratch.branch, + createdAt: scratch.created_at, + }); + void refreshWorktreeCount(origin); + // Switch the project's checkout to the new scratch worktree in place, then + // load it so a spawned agent terminal lands in the right cwd. + selectWorktree(activeTabId.value, scratch.path); + await openRepo(scratch.path); + return scratch; +} + /** Create the AI-task scratch worktree once the user has named it. */ async function confirmNewAiTask(name: string) { if (!repoFolderPath.value || activeTabId.value === null) return; aiTaskNameBusy.value = true; try { - // Always base the scratch on the active project's root, not whatever - // worktree is currently selected, so AI tasks branch from the project. - const projectTab = repoTabs.value.find((t) => t.id === activeTabId.value); - const origin = projectTab?.path ?? repoFolderPath.value; - const scratch = await scratchWorktreeCreate(origin, undefined, name || undefined); - aiTasks.register({ - path: scratch.path, - originCwd: origin, - branch: scratch.branch, - createdAt: scratch.created_at, - }); - void refreshWorktreeCount(origin); + const scratch = await createAiTaskScratchWorktree(name); aiTaskNamePrompt.value = false; - // Switch the project's checkout to the new scratch worktree in place, then - // load it and spawn the agent terminal there. - selectWorktree(activeTabId.value, scratch.path); - await openRepo(scratch.path); + if (!scratch) return; await openTerminalTab(scratch.path, "claude"); } catch (err) { aiTaskNamePrompt.value = false; @@ -1833,6 +1850,39 @@ async function confirmNewAiTask(name: string) { } } +/** + * v3.7.0 (Task 3) — "Fix with agent" from `CommitReviewModal`: types the + * findings prompt into a fresh agent PTY WITHOUT pressing Enter (plan + * decision D7 — no `terminal_open` "initial prompt" param exists, and typing + * without submitting avoids racing the agent TUI's boot while keeping "let + * an agent edit my files" a deliberate human gesture). Optionally opens the + * agent in a scratch worktree first (reuses `createAiTaskScratchWorktree`). + */ +async function onCommitReviewFixWithAgent(payload: { tool: TerminalTabType; scratch: boolean }) { + const prompt = buildReviewFixPrompt(commitReview.findings.value); + if (!prompt) return; + showCommitReviewModal.value = false; + try { + let cwd: string | undefined; + if (payload.scratch) { + const scratch = await createAiTaskScratchWorktree(); + if (!scratch) return; + cwd = scratch.path; + } else { + cwd = repoFolderPath.value ?? undefined; + } + const tab = await openTerminalTab(cwd, payload.tool); + // `sessionId` is -1 until `terminalOpen` resolves inside `openTab` — + // `openTerminalTab` only returns after that await settles, but guard + // explicitly anyway (per plan) rather than relying on that implicitly. + if (!tab || tab.sessionId < 0) return; + await termSessions.write(tab.sessionId, prompt); + commitReview.armReReview(); + } catch (err) { + reportAgentLaunchError(payload.tool, err); + } +} + /** * Definitively close a repo (project) tab. Closing a project never deletes its * worktrees — it only drops the tab — so we just confirm (the new submenu @@ -3844,6 +3894,7 @@ onUnmounted(() => { :truncated="commitReview.truncated.value" @jump="onJumpToCommitReviewFinding($event)" @dismiss="commitReview.dismiss($event)" + @fix-with-agent="onCommitReviewFixWithAgent($event)" @close="showCommitReviewModal = false" /> diff --git a/apps/desktop/src/components/CommitReviewModal.vue b/apps/desktop/src/components/CommitReviewModal.vue index 2f10c1f0..9ddfb3ef 100644 --- a/apps/desktop/src/components/CommitReviewModal.vue +++ b/apps/desktop/src/components/CommitReviewModal.vue @@ -4,14 +4,15 @@ * * Task 1b (v3.7.0) — summary + severity-sorted finding list for the * staged-diff Commit Review pass. Modelled on `SecretsFindingsModal.vue`. - * "Fix with agent" (Task 3) and the iteration/coverage slot (Task 4) are - * out of scope for this PR and land as plain follow-ups on this component. + * Task 3 adds "Fix with agent" (tool + scratch-worktree picker); Task 4 adds + * the iteration/coverage line. */ -import { computed } from "vue"; +import { computed, ref } from "vue"; import BaseModal from "./BaseModal.vue"; import { useI18n } from "../composables/useI18n"; import type { ReviewFinding } from "../composables/usePrPreReview"; import { sortFindingsForReview } from "../composables/useCommitReviewNav"; +import type { TerminalTabType } from "../composables/useTerminalSessions"; const props = withDefaults( defineProps<{ @@ -20,18 +21,47 @@ const props = withDefaults( summary?: string; /** True when the staged diff was truncated by the file/byte cap. */ truncated?: boolean; + /** Task 4 — review passes run this cycle. 0 hides the stats line. */ + iterations?: number; + /** Task 4 — share (0-100) of the current staged diff already reviewed. */ + coverage?: number; }>(), - { summary: "", truncated: false }, + { summary: "", truncated: false, iterations: 0, coverage: 0 }, ); const emit = defineEmits<{ jump: [id: string]; dismiss: [id: string]; close: []; + "fix-with-agent": [{ tool: TerminalTabType; scratch: boolean }]; }>(); const { t } = useI18n(); +// ── Task 3 — Fix with agent ────────────────────────────────────────────── +const FIX_AGENT_TOOLS: Extract[] = [ + "claude", + "codex", + "opencode", +]; +const selectedTool = ref("claude"); +const fixInScratch = ref(false); + +const TOOL_LABEL_KEY: Record<(typeof FIX_AGENT_TOOLS)[number], "commitReview.toolClaude" | "commitReview.toolCodex" | "commitReview.toolOpencode"> = { + claude: "commitReview.toolClaude", + codex: "commitReview.toolCodex", + opencode: "commitReview.toolOpencode", +}; + +function toolLabel(tool: (typeof FIX_AGENT_TOOLS)[number]): string { + return t(TOOL_LABEL_KEY[tool]); +} + +function onFixWithAgentClick() { + if (!props.findings.length) return; + emit("fix-with-agent", { tool: selectedTool.value, scratch: fixInScratch.value }); +} + const SEVERITY_LABEL_KEY: Record = { risk: "commitReview.severityRisk", suggestion: "commitReview.severitySuggestion", @@ -58,6 +88,15 @@ const sortedFindings = computed(() => sortFindingsForReview(props.findings)); >
{{ props.summary }}
{{ t('commitReview.truncatedNotice') }}
+
+ {{ t('commitReview.iterations', props.iterations) }} + · + {{ t('commitReview.coverage', props.coverage) }} +
{{ t('commitReview.empty') }}
    @@ -81,6 +120,26 @@ const sortedFindings = computed(() => sortFindingsForReview(props.findings));