Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
561f07f
refactor(review): make the AI review engine diff-source-agnostic
Aug 13, 2026
b691ec3
feat(commit-review): add staged-diff AI review engine + opt-in setting
Aug 13, 2026
2903a58
feat(commit-review): review staged changes from the commit area with …
Aug 13, 2026
6f11346
feat(commit-review): cycle findings with n/p and show per-file counts
Aug 13, 2026
453599a
fix(commit-review): stop the modifier guard from swallowing "?"
Aug 13, 2026
2928ec6
fix(commit-review): keep colspan working on inline finding rows
Aug 13, 2026
9a99b88
fix(commit-review): give each review run its own queue instance
Aug 13, 2026
f942bfb
fix(commit-review): wire resume(), surface failures, confirm a clean …
Aug 13, 2026
bcb1275
fix(commit-review): hide the unwired auto-re-review toggle, gate the …
Aug 13, 2026
ebb9f17
docs: add v3.7.0 commit review implementation plan
Aug 13, 2026
2d3676c
feat(commit-review): pipe findings to a CLI agent with optional scrat…
Aug 17, 2026
e0d3750
feat(commit-review): track review iterations and staged-diff coverage
Aug 17, 2026
b4fdcc0
feat(commit-review): record a GitWand-Review trailer with a ran/vouch…
Aug 17, 2026
e57d98b
test(commit-review): use fake timers for the debounce test to remove …
Aug 17, 2026
8d8ab55
fix(commit-review): remove a raw NUL byte that made commitReviewState…
Aug 17, 2026
cf0efa9
fix(commit-review): bind the review cycle to HEAD and keep coverage h…
Aug 17, 2026
2e21c72
fix(commit-review): reconcile HEAD before the commit gate, fix Review…
Aug 17, 2026
026c7a0
refactor(commit-review): move the findings sort order into utils
Aug 17, 2026
8260c49
fix(commit-review): drop the scratch-worktree option from Fix with agent
Aug 17, 2026
77a10ee
fix(commit-review): recompute coverage against the full diff right be…
Aug 17, 2026
f7a8032
fix(commit-review): strip all C0 control characters from fix-with-age…
Aug 17, 2026
cf839cc
fix(commit-review): keep last known coverage instead of an optimistic…
Aug 17, 2026
65b146a
feat(commit-review): per-repo .gitwandrc opt-in for the commit review…
Aug 19, 2026
b442490
feat(commit-review): composable pre-commit hook script (secrets + rev…
Aug 19, 2026
eaf3dec
feat(commit-review): two-row Hooks panel for secrets and commit review
Aug 19, 2026
c1e7fd2
fix(commit-review): wire .gitwandrc opt-in to the UI gate, fix stale …
Aug 19, 2026
24611ef
fix(commit-review): correct stale hook copy for the sectioned pre-com…
Aug 19, 2026
a8a41e7
refactor(commit-review): drop dead threshold/cap fields from Effectiv…
Aug 19, 2026
bed6afa
fix(commit-review): close the rcOverride race, serialize hook writes,…
Aug 19, 2026
fed6abe
fix(desktop): stop a routine status poll from wiping commit-review fi…
Aug 19, 2026
5edd14d
fix(secrets): rescan the staged diff at commit time instead of trusti…
Aug 19, 2026
3f2417f
fix(commit-review): abort an in-flight review when the post-commit st…
Aug 19, 2026
1a78cff
fix(commit-review): re-prompt the commit gate when an unresolved risk…
Aug 19, 2026
1b69e72
fix(commit-review): distinguish not-reviewed from reviewed-clean in t…
Aug 19, 2026
a8bd338
fix(commit-review): guard run()'s coverage writes with the generation…
Aug 19, 2026
803af8d
fix(dev-server): send CORS headers on the read-gitwandrc success paths
Aug 19, 2026
30739a3
fix(hooks): surface a distinct state when a non-GitWand pre-commit ho…
Aug 19, 2026
7867dcc
docs(commit-review): describe the real byte-budget guarantee
Aug 19, 2026
bc6953b
fix(diff): drop the phantom trailing context line from parseFileDiff
Aug 19, 2026
a6e445c
feat(a11y): trap and restore focus in BaseModal
Aug 19, 2026
6122074
fix(folder-picker): select the typed path without requiring Enter first
Aug 19, 2026
94a6cf4
fix(settings): state that the AI toggle also gates the review features
Aug 19, 2026
9a0b80e
fix(settings): translate the nav group headers and flag the local CLI…
Aug 19, 2026
7ef903c
style: remove em dashes from this plan's own comments and test names
Aug 19, 2026
a35321d
docs: add the review-fixes implementation plan
Aug 19, 2026
192e407
Merge remote-tracking branch 'origin/main' into feat/v3.7-commit-revi…
Aug 19, 2026
69c3767
test(desktop): opt these files into jsdom after the merge from main
Aug 19, 2026
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
8 changes: 4 additions & 4 deletions apps/desktop/dev-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4715,13 +4715,13 @@ async function handleRequest(req, res) {
// 1. .gitwandrc
const rcPath = join(base, ".gitwandrc");
if (existsSync(rcPath)) {
return res.writeHead(200, { "Content-Type": "text/plain" }).end(readFileSync(rcPath, "utf-8"));
return res.writeHead(200, { ...corsHeaders(req), "Content-Type": "text/plain" }).end(readFileSync(rcPath, "utf-8"));
}

// 2. .gitwandrc.json
const rcJsonPath = join(base, ".gitwandrc.json");
if (existsSync(rcJsonPath)) {
return res.writeHead(200, { "Content-Type": "text/plain" }).end(readFileSync(rcJsonPath, "utf-8"));
return res.writeHead(200, { ...corsHeaders(req), "Content-Type": "text/plain" }).end(readFileSync(rcJsonPath, "utf-8"));
}

// 3. "gitwand" key in package.json
Expand All @@ -4730,13 +4730,13 @@ async function handleRequest(req, res) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
if (pkg.gitwand) {
return res.writeHead(200, { "Content-Type": "text/plain" }).end(JSON.stringify(pkg.gitwand));
return res.writeHead(200, { ...corsHeaders(req), "Content-Type": "text/plain" }).end(JSON.stringify(pkg.gitwand));
}
} catch { /* ignore parse errors */ }
}

// Not found — return empty string (same as Rust backend)
return res.writeHead(200, { "Content-Type": "text/plain" }).end("");
return res.writeHead(200, { ...corsHeaders(req), "Content-Type": "text/plain" }).end("");
} catch (err) {
return jsonResponse(req, res, { error: err.stderr?.toString() || err.message }, 500);
}
Expand Down
495 changes: 471 additions & 24 deletions apps/desktop/src/App.vue

Large diffs are not rendered by default.

83 changes: 82 additions & 1 deletion apps/desktop/src/components/BaseModal.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from "vue";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useI18n } from "../composables/useI18n";
import { focusableWithin, nextTrapTarget } from "../utils/focusTrap";

type ModalSize = "sm" | "md" | "lg" | "xl" | "full" | "2x";
type ModalPosition = "center" | "top";
Expand Down Expand Up @@ -29,6 +30,23 @@ const props = withDefaults(
bodyFlush?: boolean;
/** If true, the body is not scrollable; child content owns its own scrolling. */
scrollOwn?: boolean;
/**
* v3.7.0 review-round fix (finding #11): trap Tab/Shift+Tab focus
* inside the panel, wrapping at the ends. Escape hatch for a modal that
* embeds something that must own its own Tab handling in a way the
* generic trap can't anticipate.
*/
trapFocus?: boolean;
/**
* v3.7.0 review-round fix (finding #11): focus the panel itself on
* mount. Set to `false` for a modal that already self-focuses one of
* its own inputs from its own `onMounted` (that self-focus runs AFTER
* this component's synchronous `onMounted`, Vue fires a child's
* `mounted` before its parent's, so leaving `autoFocus` at its
* default `true` would still be harmless, but the prop exists so a
* modal can opt out explicitly and document its own focus target).
*/
autoFocus?: boolean;
}>(),
{
title: "",
Expand All @@ -42,6 +60,8 @@ const props = withDefaults(
hideClose: false,
bodyFlush: false,
scrollOwn: false,
trapFocus: true,
autoFocus: true,
}
);

Expand Down Expand Up @@ -70,12 +90,64 @@ function onKeyDown(e: KeyboardEvent) {
}
}

// v3.7.0 review-round fix (finding #11): focus trap, initial focus, focus
// restore. See docs/superpowers/plans/2026-08-19-v3.7.0-commit-review-review-fixes.md,
// Task 11, for the full design rationale (nested modals, CodeMirror/xterm).
const panelRef = ref<HTMLElement | null>(null);
let previouslyFocused: HTMLElement | null = null;

/**
* Bubble-phase `keydown` listener on the PANEL element (not `window`, unlike
* the Escape handler above): nested modals (e.g. `askConfirm` layered over
* another modal) each get their own trap, and only the one that actually
* contains focus reacts. An inner component that owns Tab itself
* (CodeMirror indent, an xterm terminal) runs first in the bubble phase, so
* this bails on `e.defaultPrevented`.
*/
function onPanelKeyDown(e: KeyboardEvent) {
if (e.key !== "Tab" || !props.trapFocus || e.defaultPrevented) return;
const panel = panelRef.value;
if (!panel) return;

const focusables = focusableWithin(panel);
if (focusables.length === 0) {
// Nothing to wrap between. Keep focus pinned on the panel itself.
e.preventDefault();
panel.focus();
return;
}

const target = nextTrapTarget(focusables, document.activeElement, e.shiftKey);
if (target) {
e.preventDefault();
target.focus();
}
// target === null means `active` is a middle element: the browser's own
// default Tab behavior already moves focus correctly, so this is a no-op.
}

onMounted(() => {
window.addEventListener("keydown", onKeyDown);

// Synchronous, NOT wrapped in `nextTick`: Vue fires a child's `mounted`
// before its parent's, and several modals self-focus an input from their
// OWN `onMounted` + `nextTick` (AiTaskNameModal.vue, CloneModal.vue,
// ForkModal.vue, FolderPicker.vue's own overlay opened from CloneModal).
// A `nextTick` here would run in the same microtask flush and could win
// the race against, or after, those, clobbering their explicit input
// focus. Doing this synchronously means it always happens BEFORE the
// parent component's own `onMounted` runs, so a parent's `nextTick`-deferred
// input focus always wins over this panel focus, exactly like before this
// fix existed.
previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
if (props.autoFocus) panelRef.value?.focus();
});

onUnmounted(() => {
window.removeEventListener("keydown", onKeyDown);
if (previouslyFocused && document.contains(previouslyFocused)) {
previouslyFocused.focus();
}
});
</script>

Expand All @@ -87,11 +159,14 @@ onUnmounted(() => {
@click.self="onBackdropClick"
>
<div
ref="panelRef"
class="base-modal"
:class="sizeClass"
:role="role"
aria-modal="true"
:aria-label="ariaLabel || title || undefined"
tabindex="-1"
@keydown="onPanelKeyDown"
>
<!-- Header -->
<header v-if="!hideHeader" class="base-modal__header">
Expand Down Expand Up @@ -180,6 +255,12 @@ onUnmounted(() => {
animation: bm-slide-in var(--transition-slow) ease;
}

/* v3.7.0 review-round fix (finding #11): the panel is `tabindex="-1"` so it
is programmatically focusable (initial focus, empty-focusable-set fallback);
suppress the focus ring that would otherwise draw on the whole panel.
A single class + pseudo-class, does not touch `.bm-btn` (AGENTS.md). */
.base-modal:focus { outline: none; }

.base-modal--sm { width: min(400px, 92vw); }
.base-modal--md { width: min(520px, 92vw); }
.base-modal--lg { width: min(640px, 92vw); }
Expand Down
142 changes: 142 additions & 0 deletions apps/desktop/src/components/CommitReviewDecisionModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<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 { computed } from "vue";
import { useI18n } from "../composables/useI18n";
import BaseModal from "./BaseModal.vue";

const props = 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;
/**
* Live, undismissed risk-severity finding count (v3.7.0 review-round fix
* #2/#5). When > 0, shown as a distinct warning line above the actions.
*/
riskCount?: number;
}>(),
{ findingsCount: 0, iterations: 0, coverage: 0, riskCount: 0 },
);

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

const { t } = useI18n();

/**
* Three mutually exclusive context strings, driven by iterations/findingsCount
* (v3.7.0 review-round fix #5): the plain "{0} finding(s)" string used to
* render identically whether a review ran and found nothing, or no review
* ever ran at all.
*/
const contextLabel = computed(() => {
if (props.iterations === 0) return t("commitReview.decisionNotReviewed");
if (props.findingsCount === 0) return t("commitReview.decisionReviewedClean");
return t("commitReview.modalSubtitle", props.findingsCount);
});
</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>{{ contextLabel }}</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 v-if="riskCount > 0" class="crdm-risk">{{ t('commitReview.decisionRiskWarning', riskCount) }}</p>
<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;
}

.crdm-risk {
color: var(--color-danger);
font-size: var(--font-size-sm);
margin: 0 0 var(--space-3);
}

/* Flat, single-class modifiers — never prefix `.bm-btn` with an ancestor
selector (AGENTS.md modal-CSS rule). */
</style>
Loading
Loading