feat(ui): unify change comparisons and compact message actions

Branch comparisons could retain an old base or omit local edits, while Changes and walkthrough selected their sources independently.

Share branch and commit selectors across both panels, honor exact refs, include local branch edits, and support first-parent commit diffs with the latest 50 commits. Compact message metadata and move touch actions into a shared sheet.

Validated with workspace type-check, lint and build, focused Git and UI tests, and maintainer testing in the app.
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 17:41:14 +03:00
parent ec7db447bd
commit 0b899d1153
45 changed files with 2131 additions and 599 deletions
@@ -52,20 +52,39 @@ written against staged code never silently re-anchors onto an unstaged edit.
| Kind | Sections | Notes |
|---|---|---|
| `working-tree` (`all` \| `staged` \| `working`) | `staged`, `working` | Untracked files are fetched individually because `git diff` omits them |
| `branch` | `branch` | `getRangeDiff` uses three-dot `base...head`, so work merged in from the base branch is excluded |
| `pr` | `pr:<number>` | GitHub returns the merge-base diff, matching the branch semantics |
| `branch` | `branch` | `getRangeDiff` with `includeWorkingTree: true` compares the selected merge base with current files, including committed and local work in one net diff |
| `commit` | `commit` | `getCommitDiff` compares the full selected commit hash with its first parent; root commits compare with an empty tree |
| `pr` | `pr:<number>` | GitHub's committed pull-request diff, without local working-tree changes |
For the current-branch source, the UI takes the base from the default branch of
the current branch's tracking remote (`defaultBranches` in the branches
response), and only then falls back to the conventional names. It does not offer
the source at all when the chosen base exists neither locally nor on a remote —
a repository whose default is neither `main`, `master` nor `develop` used to be
handed `main...<head>`, which git rejects outright.
Changes and walkthrough resolve the current branch's base through
`packages/ui/src/hooks/useBranchComparisonBase.ts`. An explicit choice in Changes
outranks reflog detection. Both toolbars use
`packages/ui/src/components/views/git/BranchComparisonSelector.tsx` to select or
change the base directly. Walkthrough allows selecting Branch before a base is
known and waits for a valid choice before loading or generating. Opening
walkthrough from Changes carries the selected base and head; later selections
in either toolbar update both comparisons.
A base that exists only on a remote still works: `getRangeDiff` prefers
`origin/<base>` when it exists, and otherwise resolves the base through whichever
remote carries it, because a bare branch name git cannot find in `refs/heads`
fails the same way.
Commit mode uses the shared `CommitComparisonSelector` in both toolbars. It
lists the latest 50 commits reachable from the checked-out branch, with subject,
author, date, and short hash. Opening the picker refreshes that list; selecting a
commit changes the comparison, not the checkout. Changes hands the selected full
hash to walkthrough. The server accepts full object IDs for commit sources and
keys their cache entries and generation jobs as `commit:<hash>`, so reviews of
different commits cannot overwrite each other. Existing source keys keep their
format. Commit reads have no working-tree freshness dependency, and selecting a
commit never starts model generation.
The Git module owns exact-ref and working-tree comparison semantics. Local and
remote bases remain distinct, and a checkout during a branch review requires
the source to be resolved for the new branch rather than including another
branch's local files.
Successful status refreshes invalidate the visible branch comparison even when
file names and insertion/deletion counts stay the same. Walkthrough refreshes
its current hunk index while visible; regeneration remains user-initiated. The
content-addressed cache continues to reuse an old review only when its hunks
match, and otherwise reports stale anchors and uncovered current hunks.
The panel offers the current branch's pull request on its own: it registers with
the shared GitHub PR status store (`useGitHubPrStatusStore`) rather than waiting
@@ -86,7 +86,9 @@ export function buildPrompt({ digest, fileCount, hunkCount, source, previousWalk
? `Uncommitted local changes (${source.scope === 'all' ? 'staged and unstaged' : source.scope}).`
: source.kind === 'branch'
? `All work on branch "${source.headRef}" that is not in "${source.baseRef}". Changes merged in from ${source.baseRef} are already excluded.`
: `Pull request #${source.number}.`;
: source.kind === 'commit'
? `Only the changes introduced by commit ${source.hash}, relative to its first parent (or the empty tree for a root commit).`
: `Pull request #${source.number}.`;
const prompt = `Reviewing: ${sourceLine}
+23 -2
View File
@@ -1,4 +1,4 @@
import { getDiff, getRangeDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js';
import { getDiff, getRangeDiff, getCommitDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js';
// A walkthrough source resolves to one or more diff *sections*. A section is a
// patch plus the scope its hunk ids live in; keeping staged and working-tree
@@ -48,6 +48,18 @@ export function parseSource(raw) {
return { kind: 'pr', number };
}
if (raw.kind === 'commit') {
// Sources are content-addressed: accept a full object id, never a moving ref.
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(raw.hash)) {
throw new WalkthroughSourceError('commit sources require a full commit hash');
}
try {
return { kind: 'commit', hash: raw.hash.toLowerCase() };
} catch {
throw new WalkthroughSourceError('commit sources require a full commit hash');
}
}
throw new WalkthroughSourceError(`Unknown source kind "${String(raw.kind)}"`);
}
@@ -58,6 +70,7 @@ export function parseSource(raw) {
export function sourceKey(source) {
if (source.kind === 'working-tree') return `working-tree:${source.scope}`;
if (source.kind === 'branch') return `branch:${source.baseRef}...${source.headRef}`;
if (source.kind === 'commit') return `commit:${source.hash}`;
return `pr:${source.number}`;
}
@@ -97,13 +110,21 @@ export async function loadSourceSections(directory, source, { getPullRequestDiff
}
if (source.kind === 'branch') {
const patch = await getRangeDiff(directory, { base: source.baseRef, head: source.headRef });
const patch = await getRangeDiff(directory, { base: source.baseRef, head: source.headRef, includeWorkingTree: true });
return {
sections: patch && patch.trim() ? [{ scope: 'branch', patch }] : [],
meta: { baseRef: source.baseRef, headRef: source.headRef },
};
}
if (source.kind === 'commit') {
const patch = await getCommitDiff(directory, { hash: source.hash });
return {
sections: patch.trim() ? [{ scope: 'commit', patch }] : [],
meta: { hash: source.hash },
};
}
if (typeof getPullRequestDiff !== 'function') {
throw new WalkthroughSourceError('Pull request diffs are unavailable', 500);
}