Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ _Inspired by [git-lrc](https://github.com/HexmosTech/git-lrc) (HexmosTech). Comm

- **Review staged changes** — one button in the commit area: AI pass over the staged diff, inline findings with severity badges anchored in the diff + a short summary. Generalize `usePrHunkCritique` from PR hunks to any `GitDiff` — the same engine as the v3.5.0 pre-review pass, pointed at the index
- **Issue navigation** — cycle finding-to-finding (reuses the v3.5.0 keyboard model), per-file finding counts in the staged list
- **Fix with agent** — git-lrc makes you copy-paste issues back to your agent; we pipe them: "Fix with agent" sends the findings to Claude Code / opencode / Codex (Agent Sessions), optionally in an AI-task scratch worktree; re-review triggers on the next staging change
- **Fix with agent** — git-lrc makes you copy-paste issues back to your agent; we pipe them: "Fix with agent" sends the findings to Claude Code / opencode / Codex (Agent Sessions), always against the current repo; re-review triggers on the next staging change. The originally-planned "optionally in an AI-task scratch worktree" variant was cut from PR2 after manual QA against real claude/codex CLIs found a brand-new scratch worktree always hits a first-run "trust this directory?" onboarding screen that misinterprets the piped prompt as menu navigation (drove a real `brew upgrade --cask codex` in testing). Revisit once there's a real fix — pre-trusting the directory before launching the agent, or detecting the onboarding screen before writing — tracked as a v3.7.x/v3.8.0 follow-up
- **Iterations & coverage** — track review→fix→review cycles and the share of the final staged diff already reviewed (`iter:N`, `coverage:X%`)
- **Review / Vouch / Skip** — explicit three-state decision at commit time, non-blocking (same UX contract as the v3.5.0 secrets scanner): reviewed by AI, vouched personally, or skipped — recorded as a commit trailer `GitWand-Review: ran|vouched|skipped (iter:N, coverage:X%)` via the existing trailers support (v1.9.0), so the team sees review status right in `git log`
- **Opt-in & scoped** — per-repo enable in `.gitwandrc` + Settings; optional pre-commit hook wiring via Settings > Hooks alongside the v3.5.0 scanner
Expand Down
305 changes: 277 additions & 28 deletions apps/desktop/src/App.vue

Large diffs are not rendered by default.

117 changes: 117 additions & 0 deletions apps/desktop/src/components/CommitReviewDecisionModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<script setup lang="ts">
/**
* CommitReviewDecisionModal.vue
*
* Task 5 (v3.7.0) — the Review / Vouch / Skip decision, shown before a
* commit goes through when Commit Review is on. Same non-blocking UX
* contract as the v3.5.0 secrets scanner: this never hard-stops a commit.
*
* "Review now" runs the pass and keeps the commit pending (App.vue re-opens
* CommitReviewModal so the user can look at findings, then commits again
* when satisfied). "Vouch"/"Skip" record an explicit decision and let the
* commit proceed immediately. Cancelling (Escape/backdrop/Cancel button, all
* routed through BaseModal's `close`) cancels the commit outright — it never
* silently records "skipped" (decision D8: skipping must be a deliberate,
* explicit click, or the trailer would lie about intent).
*
* No custom keyboard shortcuts are added here — `BaseModal` already maps
* Escape/backdrop-click to `close`, which is exactly the "cancel" behavior
* this modal wants, so there's nothing else to wire (and nothing else that
* could get a modifier-key guard wrong).
*/
import { useI18n } from "../composables/useI18n";
import BaseModal from "./BaseModal.vue";

withDefaults(
defineProps<{
/** Active (filtered) findings count on the staged diff, for context. */
findingsCount?: number;
/** Review passes already completed this cycle. */
iterations?: number;
/** Share (0-100) of the current staged diff already reviewed. */
coverage?: number;
}>(),
{ findingsCount: 0, iterations: 0, coverage: 0 },
);

const emit = defineEmits<{
"review-now": [];
vouch: [];
skip: [];
close: [];
}>();

const { t } = useI18n();
</script>

<template>
<BaseModal
:title="t('commitReview.decisionTitle')"
size="md"
role="alertdialog"
@close="emit('close')"
>
<p class="crdm-message">{{ t('commitReview.decisionMessage') }}</p>
<div class="crdm-context">
<span>{{ t('commitReview.modalSubtitle', findingsCount) }}</span>
<template v-if="iterations > 0">
<span class="crdm-context__sep">·</span>
<span>{{ t('commitReview.iterations', iterations) }}</span>
<span class="crdm-context__sep">·</span>
<span>{{ t('commitReview.coverage', coverage) }}</span>
</template>
</div>
<p class="crdm-hint">{{ t('commitReview.trailerHint') }}</p>

<template #footer>
<button type="button" class="bm-btn bm-btn--ghost crdm-cancel" @click="emit('close')">
{{ t('commitReview.decisionCancel') }}
</button>
<button
type="button"
class="bm-btn bm-btn--ghost crdm-skip"
:title="t('commitReview.skipHint')"
@click="emit('skip')"
>
{{ t('commitReview.decisionSkip') }}
</button>
<button
type="button"
class="bm-btn bm-btn--ghost crdm-vouch"
:title="t('commitReview.vouchHint')"
@click="emit('vouch')"
>
{{ t('commitReview.decisionVouch') }}
</button>
<button type="button" class="bm-btn bm-btn--primary crdm-review-now" @click="emit('review-now')">
{{ t('commitReview.decisionReviewNow') }}
</button>
</template>
</BaseModal>
</template>

<style scoped>
.crdm-message {
color: var(--color-text);
margin: 0 0 var(--space-3);
}

.crdm-context {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
margin-bottom: var(--space-3);
}

.crdm-hint {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
margin: 0;
}

/* Flat, single-class modifiers — never prefix `.bm-btn` with an ancestor
selector (AGENTS.md modal-CSS rule). */
</style>
100 changes: 96 additions & 4 deletions apps/desktop/src/components/CommitReviewModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,26 @@
*
* 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 picker); Task 4 adds the
* iteration/coverage line.
*
* Scope-narrowed for PR2 (deliberate, not a bug fix): "Fix with agent" no
* longer offers a scratch-worktree option. Manual QA against real
* claude/codex CLIs found that a brand-new scratch worktree always hits a
* first-run "trust this directory?" onboarding screen that misinterprets
* the piped prompt as menu navigation (it drove a real `brew upgrade
* --cask codex` in testing, from the CLI's default "Update now" option).
* "Fix with agent" only ever targets the CURRENT repo now (already
* trusted, no onboarding screen). Revisit scratch-worktree support once
* there's a real fix — e.g. pre-trusting the directory before launching
* the agent, or detecting the onboarding screen before writing.
*/
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<{
Expand All @@ -20,18 +32,46 @@ 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 }];
}>();

const { t } = useI18n();

// ── Task 3 — Fix with agent ──────────────────────────────────────────────
const FIX_AGENT_TOOLS: Extract<TerminalTabType, "claude" | "codex" | "opencode">[] = [
"claude",
"codex",
"opencode",
];
const selectedTool = ref<TerminalTabType>("claude");

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 });
}

const SEVERITY_LABEL_KEY: Record<ReviewFinding["severity"], "commitReview.severityRisk" | "commitReview.severitySuggestion" | "commitReview.severityNit"> = {
risk: "commitReview.severityRisk",
suggestion: "commitReview.severitySuggestion",
Expand All @@ -58,6 +98,15 @@ const sortedFindings = computed(() => sortFindingsForReview(props.findings));
>
<div v-if="props.summary" class="crm-summary">{{ props.summary }}</div>
<div v-if="props.truncated" class="crm-truncated">{{ t('commitReview.truncatedNotice') }}</div>
<div
v-if="props.iterations > 0"
class="crm-stats"
:title="`${t('commitReview.iterationsTooltip')} · ${t('commitReview.coverageTooltip')}`"
>
<span class="crm-stats__iter">{{ t('commitReview.iterations', props.iterations) }}</span>
<span class="crm-stats__sep">·</span>
<span class="crm-stats__coverage">{{ t('commitReview.coverage', props.coverage) }}</span>
</div>

<div v-if="sortedFindings.length === 0" class="crm-empty">{{ t('commitReview.empty') }}</div>
<ul v-else class="crm-list">
Expand All @@ -81,6 +130,22 @@ const sortedFindings = computed(() => sortFindingsForReview(props.findings));
</ul>

<template #footer>
<div class="crm-fix-agent">
<select v-model="selectedTool" class="crm-fix-tool" :aria-label="t('commitReview.fixWithAgent')">
<option v-for="tool in FIX_AGENT_TOOLS" :key="tool" :value="tool">
{{ toolLabel(tool) }}
</option>
</select>
<button
type="button"
class="bm-btn bm-btn--primary crm-btn-compact crm-fix-btn"
:disabled="props.findings.length === 0"
:title="t('commitReview.fixWithAgentTooltip')"
@click="onFixWithAgentClick"
>
{{ t('commitReview.fixWithAgent') }}
</button>
</div>
<button type="button" class="bm-btn bm-btn--ghost crm-footer-close" @click="emit('close')">
{{ t('commitReview.close') }}
</button>
Expand All @@ -100,6 +165,33 @@ const sortedFindings = computed(() => sortFindingsForReview(props.findings));
margin-bottom: var(--space-4);
}

.crm-stats {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-4);
}

.crm-fix-agent {
display: flex;
align-items: center;
gap: var(--space-3);
/* Pushes this group to the left of the footer while `justify-content:
flex-end` on `.base-modal__footer` keeps Close pinned to the right. */
margin-right: auto;
}

.crm-fix-tool {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
}

.crm-empty {
color: var(--color-text-muted);
text-align: center;
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/src/components/RepoSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ const props = defineProps<{
commitReviewProgress?: { done: number; total: number };
/** v3.7.0 — Commit Review (Task 2): per-file finding count, keyed by path. */
reviewFindingsByFile?: Record<string, number>;
/** v3.7.0 (Task 4) — review passes completed this cycle; 0 = none yet. */
commitReviewIterations?: number;
/** v3.7.0 (Task 4) — share (0-100) of the current staged diff already reviewed. */
commitReviewCoverage?: number;
}>();

const emit = defineEmits<{
Expand Down Expand Up @@ -111,6 +115,16 @@ const emit = defineEmits<{

const { t, locale } = useI18n();

/** v3.7.0 (Task 4) — the findings badge's tooltip gains an iter/coverage
* suffix once at least one review pass has completed this cycle. */
const commitReviewBadgeTooltip = computed(() => {
const base = t('commitReview.badgeTooltip', props.commitReviewFindingsCount ?? 0);
if (!props.commitReviewIterations) return base;
const iter = t('commitReview.iterations', props.commitReviewIterations);
const cov = t('commitReview.coverage', props.commitReviewCoverage ?? 0);
return `${base} · ${iter} · ${cov}`;
});

/** Resolved pane (legacy callers omit the prop → render everything). */
const pane = computed(() => props.pane ?? "all");
/** True when the given pane slice should render. */
Expand Down Expand Up @@ -1727,7 +1741,7 @@ function formatActivityDate(dateStr: string): string {
v-if="(commitReviewFindingsCount ?? 0) > 0"
type="button"
class="commit-review-badge"
:title="t('commitReview.badgeTooltip', commitReviewFindingsCount ?? 0)"
:title="commitReviewBadgeTooltip"
@click="emit('openCommitReview')"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
Expand Down
20 changes: 14 additions & 6 deletions apps/desktop/src/components/SettingsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2770,12 +2770,20 @@ function deleteReleaseNoteTemplate(id: string) {
<span class="sp-hint">{{ t('settings.commitReview.enabledHint') }}</span>
</div>

<!-- commitReviewAutoReReview (verifier issue #7): the setting field
exists (useSettings.ts / this file's local Settings interface)
but its only consumer is the "Fix with agent" one-shot
re-review trigger, which is Task 3 and out of scope for this
PR. Hidden here on purpose until PR2 ships that consumer,
rather than showing a toggle that visibly does nothing yet. -->
<!-- commitReviewAutoReReview (Task 3, v3.7.0) — now wired to the
"Fix with agent" one-shot re-review trigger
(useCommitReview.armReReview / onStagedSetChanged). Shown
only when Commit Review itself is on, matching the shape
of the master switch it depends on. -->
<div v-if="settings.commitReviewEnabled" class="sp-row sp-row--checkbox">
<label class="sp-checkbox-label" for="setting-commit-review-auto-re-review">
<input id="setting-commit-review-auto-re-review" type="checkbox" class="sp-checkbox"
:checked="settings.commitReviewAutoReReview"
@change="updateSetting('commitReviewAutoReReview', ($event.target as HTMLInputElement).checked)" />
<span>{{ t('settings.commitReview.autoReReview') }}</span>
</label>
<span class="sp-hint">{{ t('settings.commitReview.autoReReviewHint') }}</span>
</div>
</div>

<!-- ─── Prompt Presets (v2.13) ─────────────────── -->
Expand Down
Loading
Loading