feat(vcs): add stacked jj diffs from main
12 files changed,  +519, -51
M README.md
+14, -3
 1@@ -67,12 +67,12 @@ PORT=5679 lcr /path/to/other # Second project on port 5679
 2 5. Approve individual files (checkbox in sidebar) — in git repos approved files are staged; in jj repos approval is tracked by the tool
 3 6. When done, click **Review** and choose:
 4    - **Request Changes** — sends your review to Claude Code for another iteration
 5-   - **Approve & Commit** (git) / **Approve** (jj) — in git, opens the commit dialog; in jj, changes are autocommitted so the review is simply marked approved
 6+   - **Approve & Commit** (git) / **Approve** (jj) — in git, opens the commit dialog; in jj, moves the `main` bookmark forward to the reviewed change (see Stacked changes below)
 7 
 8 ### Diff scope
 9 
10 - **Uncommitted** (default) — git: uncommitted changes vs HEAD; jj: changes in the working-copy commit `@` vs its parent (`jj diff -r @`). If `@` is a merge, jj diffs against the auto-merge of all parents.
11-- **Worktree** — git: the full branch diff (all commits since the branch diverged from main/master/development, plus uncommitted changes); jj: the diff from `trunk()` to `@`
12+- **Worktree** — git: the full branch diff (all commits since the branch diverged from main/master/development, plus uncommitted changes); jj: the diff from the `main` bookmark to the change being viewed
13 
14 ### Diff modes
15 
16@@ -107,6 +107,17 @@ Click the checkbox next to any file in the sidebar to approve it. Approved files
17 
18 If an approved file is modified again, its approval status is reset.
19 
20+### Stacked changes (jj)
21+
22+In jj repositories the sidebar shows a **Changes** panel below the file tree, listing every change that descends from the `main` bookmark (newest first). This supports a feature-by-feature session workflow:
23+
24+- The agent stacks changes on top of `main` (squashing as it goes); the panel tracks them and refreshes automatically as work lands.
25+- Click a change to review **only that change's diff** against its parents. Selecting is view-only — it does not move your working copy `@`, so the agent is never disrupted.
26+- Click **Approve** to move the `main` bookmark forward to the change you are reviewing, marking the feature as accepted. The view then advances to the next change above it. `main` can only move forward.
27+- At the end of the session, push the accepted work yourself, e.g. `jj git push --bookmark main`.
28+
29+The local `main` bookmark is the source of truth (not `trunk()`/`main@origin`), so approvals are reflected immediately, even before you push.
30+
31 ### Keyboard shortcuts
32 
33 | Key | Action |
34@@ -197,7 +208,7 @@ bin/
35 
36 ## Notes
37 
38-- Jujutsu repos are auto-detected by the presence of a `.jj/` directory and require the `jj` CLI on `PATH`. In jj repos there is no commit step: the working copy is autocommitted, so the review action is simply **Approve**
39+- Jujutsu repos are auto-detected by the presence of a `.jj/` directory and require the `jj` CLI on `PATH`. In jj repos there is no commit step: the working copy is autocommitted, so **Approve** moves the local `main` bookmark forward to the reviewed change (`main@origin` is left untouched)
40 - Comments are stored in memory and reset when the server restarts
41 - The `.lcr-review.json` and `.lcr-review.md` export files are created in the project root — add them to `.gitignore` if needed
42 - Syntax highlighting is powered by [Shiki](https://shiki.matsu.io/) (same engine as VS Code)
A src/lib/components/ChangeStack.svelte
+58, -0
 1@@ -0,0 +1,58 @@
 2+<script lang="ts">
 3+	import { changes, mainChange, currentChange, selectChange } from '$lib/stores/changes.ts';
 4+	import { vcs } from '$lib/stores/files.ts';
 5+
 6+	let busy = $state<string | null>(null);
 7+
 8+	async function select(changeId: string) {
 9+		if (busy || changeId === $currentChange) return;
10+		busy = changeId;
11+		try {
12+			await selectChange(changeId);
13+		} catch (err) {
14+			console.error(err);
15+		} finally {
16+			busy = null;
17+		}
18+	}
19+</script>
20+
21+{#if $vcs === 'jj'}
22+	<div class="border-t border-border max-h-72 overflow-y-auto shrink-0">
23+		<div class="px-3 py-2 text-xs font-semibold text-tertiary uppercase tracking-wider flex items-center justify-between">
24+			<span>Changes ({$changes.length})</span>
25+			{#if $mainChange}
26+				<span class="text-[10px] text-muted normal-case tracking-normal font-mono" title="Bookmark main is at {$mainChange.changeId}">
27+					main: {$mainChange.changeId}
28+				</span>
29+			{/if}
30+		</div>
31+
32+		{#if $changes.length === 0}
33+			<div class="px-3 pb-3 text-xs text-muted">No changes since main</div>
34+		{:else}
35+			<div class="space-y-px px-1 pb-2">
36+				{#each $changes as change (change.changeId)}
37+					{@const active = change.changeId === $currentChange}
38+					{@const pending = busy === change.changeId}
39+					<button
40+						class="w-full text-left px-2 py-1.5 rounded flex items-start gap-2 text-xs transition-colors {active ? 'bg-hover/80 text-primary' : 'text-secondary hover:bg-hover/50'} {pending ? 'opacity-60' : ''}"
41+						onclick={() => select(change.changeId)}
42+						title={change.description || '(no description)'}
43+					>
44+						<span class="font-mono text-[10px] text-muted shrink-0 mt-0.5">{change.changeId}</span>
45+						<span class="flex-1 truncate {active ? 'font-medium' : ''}">
46+							{change.description || '(no description)'}
47+						</span>
48+						{#each change.bookmarks as bm}
49+							<span class="shrink-0 text-[10px] px-1 rounded bg-accent-green/20 text-accent-green">{bm}</span>
50+						{/each}
51+						{#if change.isWorkingCopy}
52+							<span class="shrink-0 text-[10px] px-1 rounded bg-accent-blue/20 text-accent-blue" title="Working copy @">@</span>
53+						{/if}
54+					</button>
55+				{/each}
56+			</div>
57+		{/if}
58+	</div>
59+{/if}
M src/lib/components/Toolbar.svelte
+20, -7
 1@@ -2,10 +2,19 @@
 2 	import { viewMode, showCommitDialog, showExportDialog, theme } from '$lib/stores/ui.ts';
 3 	import { stats, reviewStatus, submitReview } from '$lib/stores/review.ts';
 4 	import { diffMode, diffScope, baseBranch, approvedCount, uncommittedCount, diffStats, vcs, loadAllDiffs, setDiffScope, loadUncommittedFiles } from '$lib/stores/files.ts';
 5+	import { changes, currentChange, approveChange } from '$lib/stores/changes.ts';
 6 
 7 	let showReviewMenu = $state(false);
 8 	let toast = $state<string | null>(null);
 9 
10+	// In jj, approve is available when the viewed change is a pending descendant
11+	// of main; in git, when there are uncommitted changes.
12+	const canApprove = $derived(
13+		$vcs === 'jj'
14+			? $changes.some((c) => c.changeId === $currentChange)
15+			: $uncommittedCount.total > 0
16+	);
17+
18 	function openReviewMenu() {
19 		showReviewMenu = !showReviewMenu;
20 		if (showReviewMenu) loadUncommittedFiles();
21@@ -21,9 +30,13 @@
22 	async function handleApprove() {
23 		await submitReview('approved');
24 		if ($vcs === 'jj') {
25-			// jj commits the working copy automatically, so there is nothing to
26-			// commit — approving just finalizes the review state.
27-			showToast('Review approved');
28+			// Move the `main` bookmark forward to the change being reviewed.
29+			try {
30+				await approveChange($currentChange ?? undefined);
31+				showToast('Approved — main updated');
32+			} catch (err) {
33+				showToast(String(err));
34+			}
35 		} else {
36 			$showCommitDialog = true;
37 		}
38@@ -177,13 +190,13 @@
39 					<span class="text-accent-yellow mr-2">↻</span> Request Changes (send to AI)
40 				</button>
41 				<button
42-					class="w-full text-left px-3 py-2 text-sm transition-colors {$uncommittedCount.total === 0 ? 'text-muted cursor-not-allowed' : 'text-secondary hover:bg-hover'}"
43-					disabled={$uncommittedCount.total === 0}
44+					class="w-full text-left px-3 py-2 text-sm transition-colors {!canApprove ? 'text-muted cursor-not-allowed' : 'text-secondary hover:bg-hover'}"
45+					disabled={!canApprove}
46 					onclick={() => { handleApprove(); showReviewMenu = false; }}
47 				>
48 					<span class="text-accent-green mr-2">✓</span> {$vcs === 'jj' ? 'Approve' : 'Approve & Commit'}
49-					{#if $uncommittedCount.total === 0}
50-						<span class="text-xs text-muted ml-1">(no uncommitted changes)</span>
51+					{#if !canApprove}
52+						<span class="text-xs text-muted ml-1">(nothing pending)</span>
53 					{/if}
54 				</button>
55 			</div>
M src/lib/server/git.ts
+18, -1
 1@@ -1,5 +1,5 @@
 2 import simpleGit, { type SimpleGit } from 'simple-git';
 3-import type { FileChange, DiffMode, DiffScope } from '$lib/types/index.ts';
 4+import type { ChangeStack, FileChange, DiffMode, DiffScope } from '$lib/types/index.ts';
 5 import { getWorktreeApprovals, approveWorktreeFile, unapproveWorktreeFile } from './review-store.ts';
 6 import { getRepoDir } from './repo.ts';
 7 
 8@@ -490,3 +490,20 @@ export async function getOldRef(scope: DiffScope = 'uncommitted'): Promise<strin
 9 	}
10 	return 'HEAD';
11 }
12+
13+// Stacked-change navigation is a jj-only feature.
14+export async function getNewRef(): Promise<string | null> {
15+	return null;
16+}
17+
18+export async function getChangeStack(): Promise<ChangeStack> {
19+	return { main: null, current: null, selected: null, workingCopy: null, changes: [] };
20+}
21+
22+export async function selectChange(_changeId: string): Promise<ChangeStack> {
23+	throw new Error('Selecting changes is only supported in jj repositories.');
24+}
25+
26+export async function approveChange(_changeId?: string): Promise<ChangeStack> {
27+	throw new Error('Approving changes is only supported in jj repositories.');
28+}
M src/lib/server/jj.ts
+202, -21
  1@@ -1,9 +1,22 @@
  2 import { execFile } from 'child_process';
  3 import { promisify } from 'util';
  4-import type { FileChange, DiffMode, DiffScope } from '$lib/types/index.ts';
  5+import type {
  6+	ChangeInfo,
  7+	ChangeStack,
  8+	FileChange,
  9+	DiffMode,
 10+	DiffScope
 11+} from '$lib/types/index.ts';
 12 import { parseDiff } from './diff-parser.ts';
 13 import { getRepoDir } from './repo.ts';
 14-import { getWorktreeApprovals, approveWorktreeFile, unapproveWorktreeFile } from './review-store.ts';
 15+import {
 16+	getWorktreeApprovals,
 17+	approveWorktreeFile,
 18+	unapproveWorktreeFile,
 19+	getSelectedChangeId,
 20+	setSelectedChangeId,
 21+	clearApprovals
 22+} from './review-store.ts';
 23 
 24 const execFileAsync = promisify(execFile);
 25 
 26@@ -41,6 +54,94 @@ export async function isJjAvailable(): Promise<boolean> {
 27 	return jjAvailableCache;
 28 }
 29 
 30+// --- Revision helpers ---
 31+
 32+const T = {
 33+	changeId: 'change_id.short()',
 34+	commitId: 'commit_id.short()',
 35+	fullCommitId: 'commit_id',
 36+	description: 'description.first_line()',
 37+	bookmarks: 'bookmarks.join(",")',
 38+	workingCopy: 'if(current_working_copy, "1", "0")',
 39+	empty: 'if(empty, "1", "0")',
 40+	parents: 'parents.map(|p| p.change_id().short()).join(",")'
 41+};
 42+
 43+const CHANGE_TEMPLATE =
 44+	`${T.changeId} ++ "\\t" ++ ${T.commitId} ++ "\\t" ++ ${T.description} ++ "\\t" ++ ` +
 45+	`${T.bookmarks} ++ "\\t" ++ ${T.workingCopy} ++ "\\t" ++ ${T.empty} ++ "\\t" ++ ${T.parents} ++ "\\n"`;
 46+
 47+interface BaseInfo {
 48+	revset: string;
 49+	changeId: string;
 50+	commitId: string;
 51+	description: string;
 52+}
 53+
 54+let cachedBaseBranchInfo: { baseBranch: string | null; mergeBase: string | null } | null = null;
 55+
 56+function invalidateBaseCache(): void {
 57+	cachedBaseBranchInfo = null;
 58+}
 59+
 60+/**
 61+ * Resolve the accepted base. We use the local `main` bookmark (the thing the UI
 62+ * moves on Approve) rather than `trunk()`, which would follow `main@origin` and
 63+ * ignore local approvals.
 64+ */
 65+async function resolveBase(): Promise<BaseInfo | null> {
 66+	for (const candidate of ['main', 'main@origin', 'trunk()']) {
 67+		try {
 68+			const out = await jjExec([
 69+				'log',
 70+				'-r',
 71+				candidate,
 72+				'--no-graph',
 73+				'-T',
 74+				`${T.changeId} ++ "\\t" ++ ${T.fullCommitId} ++ "\\t" ++ ${T.description}`
 75+			]);
 76+			const line = out.trim().split('\n')[0];
 77+			if (!line) continue;
 78+			const [changeId, commitId, description] = line.split('\t');
 79+			if (!changeId || !commitId) continue;
 80+			// trunk() falls back to the root commit when there is no main line.
 81+			if (/^0+$/.test(commitId)) return null;
 82+			return { revset: candidate, changeId, commitId, description: description ?? '' };
 83+		} catch {
 84+			// candidate doesn't resolve — try the next one
 85+		}
 86+	}
 87+	return null;
 88+}
 89+
 90+async function getWorkingCopyChangeId(): Promise<string | null> {
 91+	try {
 92+		const out = await jjExec(['log', '-r', '@', '--no-graph', '-T', T.changeId]);
 93+		return out.trim().split('\n')[0] || null;
 94+	} catch {
 95+		return null;
 96+	}
 97+}
 98+
 99+function parseChanges(raw: string): ChangeInfo[] {
100+	const out: ChangeInfo[] = [];
101+	for (const line of raw.split('\n')) {
102+		if (!line.trim()) continue;
103+		const [changeId, commitId, description, bookmarks, wc, empty, parents] = line.split('\t');
104+		if (!changeId || !commitId) continue;
105+		if (empty === '1') continue; // skip empty working-copy commits
106+		out.push({
107+			changeId,
108+			commitId,
109+			description: description || '',
110+			bookmarks: (bookmarks || '').split(',').filter(Boolean),
111+			isWorkingCopy: wc === '1',
112+			parents: (parents || '').split(',').filter(Boolean)
113+		});
114+	}
115+	return out;
116+}
117+
118 // Maps new path -> status/oldPath by inspecting Git-format diff headers.
119 function parseGitStatuses(raw: string): Map<string, { status: FileChange['status']; oldPath?: string }> {
120 	const map = new Map<string, { status: FileChange['status']; oldPath?: string }>();
121@@ -92,13 +193,15 @@ function filesFromRaw(raw: string): FileChange[] {
122 // Diff revision arguments. `jj diff -r @` already handles merge commits: when @
123 // has multiple parents jj auto-merges them and diffs the merged result against
124 // @, which is exactly "changes since the parents". Do not "simplify" this to an
125-// explicit parents() revset.
126+// explicit parents() revset. When a change is selected for review we diff that
127+// revision instead of the working copy.
128 async function revArgs(scope?: DiffScope): Promise<string[]> {
129+	const rev = getSelectedChangeId() || '@';
130 	if (scope === 'worktree') {
131 		const { mergeBase } = await getBaseBranchInfo();
132-		if (mergeBase) return ['--from', mergeBase, '--to', '@'];
133+		if (mergeBase) return ['--from', mergeBase, '--to', rev];
134 	}
135-	return ['-r', '@'];
136+	return ['-r', rev];
137 }
138 
139 async function diffFor(paths: string[], scope?: DiffScope): Promise<string> {
140@@ -119,35 +222,33 @@ export async function getWorktreeStatus(): Promise<FileChange[]> {
141 	return filesFromRaw(await diffFor([], 'worktree'));
142 }
143 
144-let cachedBaseBranchInfo: { baseBranch: string | null; mergeBase: string | null } | null = null;
145-
146 export async function getBaseBranchInfo(): Promise<{ baseBranch: string | null; mergeBase: string | null }> {
147 	if (cachedBaseBranchInfo) return cachedBaseBranchInfo;
148 
149-	try {
150-		const id = (await jjExec(['log', '-r', 'trunk()', '--no-graph', '-T', 'commit_id'])).trim();
151-		// trunk() falls back to the root commit when no main-like bookmark exists.
152-		if (!id || /^0+$/.test(id)) {
153-			cachedBaseBranchInfo = { baseBranch: null, mergeBase: null };
154-			return cachedBaseBranchInfo;
155-		}
156-		const bookmarks = (
157-			await jjExec(['log', '-r', 'trunk()', '--no-graph', '-T', 'bookmarks.join(" ")'])
158-		).trim();
159-		const baseBranch = bookmarks.split(/\s+/).filter(Boolean)[0] || 'trunk';
160-		cachedBaseBranchInfo = { baseBranch, mergeBase: id };
161-	} catch {
162+	const base = await resolveBase();
163+	if (!base) {
164 		cachedBaseBranchInfo = { baseBranch: null, mergeBase: null };
165+	} else {
166+		cachedBaseBranchInfo = {
167+			baseBranch: base.revset === 'trunk()' ? 'trunk' : 'main',
168+			mergeBase: base.commitId
169+		};
170 	}
171 	return cachedBaseBranchInfo;
172 }
173 
174 export async function getOldRef(scope: DiffScope = 'uncommitted'): Promise<string> {
175+	const selected = getSelectedChangeId();
176 	if (scope === 'worktree') {
177 		const { mergeBase } = await getBaseBranchInfo();
178 		if (mergeBase) return mergeBase;
179 	}
180-	return '@-';
181+	return selected ? `${selected}-` : '@-';
182+}
183+
184+/** Revision whose file contents are the "new" side of the diff, or null for the working tree. */
185+export async function getNewRef(): Promise<string | null> {
186+	return getSelectedChangeId();
187 }
188 
189 export async function getDiffByMode(mode: DiffMode, filePath?: string, scope?: DiffScope): Promise<string> {
190@@ -182,6 +283,86 @@ export async function getFileContent(filePath: string, ref: string = '@-'): Prom
191 	}
192 }
193 
194+// --- Stacked change navigation ---
195+
196+export async function getChangeStack(): Promise<ChangeStack> {
197+	// The base can move outside this process (or on Approve), so refresh it each
198+	// time the stack is requested. Polling calls this regularly.
199+	invalidateBaseCache();
200+
201+	const base = await resolveBase();
202+	const workingCopy = await getWorkingCopyChangeId();
203+
204+	let changes: ChangeInfo[] = [];
205+	if (base) {
206+		const raw = await jjExec([
207+			'log',
208+			'-r',
209+			`${base.changeId}..`,
210+			'--no-graph',
211+			'-T',
212+			CHANGE_TEMPLATE
213+		]);
214+		changes = parseChanges(raw);
215+	}
216+
217+	// Drop a stale selection (approved, abandoned, or filtered as empty).
218+	let selected = getSelectedChangeId();
219+	if (selected && !changes.some((c) => c.changeId === selected)) {
220+		selected = null;
221+		setSelectedChangeId(null);
222+	}
223+
224+	return {
225+		main: base
226+			? { changeId: base.changeId, commitId: base.commitId, description: base.description }
227+			: null,
228+		current: selected ?? workingCopy,
229+		selected,
230+		workingCopy,
231+		changes
232+	};
233+}
234+
235+export async function selectChange(changeId: string): Promise<ChangeStack> {
236+	try {
237+		const out = await jjExec(['log', '-r', changeId, '--no-graph', '-T', T.changeId]);
238+		if (!out.trim()) throw new Error('unknown');
239+	} catch {
240+		throw new Error(`Unknown change: ${changeId}`);
241+	}
242+	setSelectedChangeId(changeId);
243+	clearApprovals();
244+	return getChangeStack();
245+}
246+
247+export async function approveChange(changeId?: string): Promise<ChangeStack> {
248+	const stack = await getChangeStack();
249+	const target = changeId || stack.selected || stack.workingCopy || null;
250+	if (!target) {
251+		throw new Error('No change to approve.');
252+	}
253+	// Forward-only: only changes that are descendants of the current main can be
254+	// approved (they are exactly the entries in `main..`).
255+	if (!stack.changes.some((c) => c.changeId === target)) {
256+		throw new Error(
257+			'Cannot approve: this change is not a descendant of main. main can only move forward.'
258+		);
259+	}
260+
261+	await jjExec(['bookmark', 'set', 'main', '-r', target]);
262+	invalidateBaseCache();
263+	clearApprovals();
264+
265+	// The approved change is now main; advance the view to its child if any.
266+	setSelectedChangeId(null);
267+	const updated = await getChangeStack();
268+	const child = updated.changes.find((c) => c.parents.includes(target));
269+	if (child) setSelectedChangeId(child.changeId);
270+
271+	return getChangeStack();
272+}
273+
274 export async function approveFile(filePath: string, _scope?: DiffScope): Promise<void> {
275 	approveWorktreeFile(filePath);
276 }
M src/lib/server/review-store.ts
+15, -0
 1@@ -6,6 +6,8 @@ let reviewStatus: 'pending' | 'approved' | 'changes_requested' = 'pending';
 2 let reviewSummary: string | undefined;
 3 const contextFiles = new Set<string>();
 4 const worktreeApprovals = new Set<string>();
 5+// jj only: the change currently being viewed (null = working copy @).
 6+let selectedChangeId: string | null = null;
 7 
 8 export function addContextFile(filePath: string): void {
 9 	contextFiles.add(filePath);
10@@ -123,12 +125,25 @@ export function getWorktreeApprovals(): Set<string> {
11 	return worktreeApprovals;
12 }
13 
14+export function clearApprovals(): void {
15+	worktreeApprovals.clear();
16+}
17+
18+export function getSelectedChangeId(): string | null {
19+	return selectedChangeId;
20+}
21+
22+export function setSelectedChangeId(changeId: string | null): void {
23+	selectedChangeId = changeId;
24+}
25+
26 export function clearAll(): void {
27 	threads.clear();
28 	reviewStatus = 'pending';
29 	reviewSummary = undefined;
30 	contextFiles.clear();
31 	worktreeApprovals.clear();
32+	selectedChangeId = null;
33 }
34 
35 export function markOutdated(threadId: string, outdated: boolean): boolean {
M src/lib/server/vcs.ts
+17, -1
 1@@ -1,6 +1,6 @@
 2 import { existsSync } from 'fs';
 3 import { join } from 'path';
 4-import type { FileChange, DiffMode, DiffScope } from '$lib/types/index.ts';
 5+import type { ChangeStack, FileChange, DiffMode, DiffScope } from '$lib/types/index.ts';
 6 import { getRepoDir } from './repo.ts';
 7 import * as git from './git.ts';
 8 import * as jj from './jj.ts';
 9@@ -64,6 +64,22 @@ export async function getOldRef(scope: DiffScope = 'uncommitted'): Promise<strin
10 	return (await impl()).getOldRef(scope);
11 }
12 
13+export async function getNewRef(): Promise<string | null> {
14+	return (await impl()).getNewRef();
15+}
16+
17+export async function getChangeStack(): Promise<ChangeStack> {
18+	return (await impl()).getChangeStack();
19+}
20+
21+export async function selectChange(changeId: string): Promise<ChangeStack> {
22+	return (await impl()).selectChange(changeId);
23+}
24+
25+export async function approveChange(changeId?: string): Promise<ChangeStack> {
26+	return (await impl()).approveChange(changeId);
27+}
28+
29 export async function getDiffByMode(
30 	mode: DiffMode,
31 	filePath?: string,
A src/lib/stores/changes.ts
+61, -0
 1@@ -0,0 +1,61 @@
 2+import { writable } from 'svelte/store';
 3+import type { ChangeInfo, ChangeStack } from '$lib/types/index.ts';
 4+import { loadFiles, loadAllDiffs } from './files.ts';
 5+
 6+export type StackResponse = ChangeStack & { vcs?: string; error?: string };
 7+
 8+export const changes = writable<ChangeInfo[]>([]);
 9+export const mainChange = writable<ChangeStack['main']>(null);
10+export const currentChange = writable<string | null>(null);
11+/** Signature of the last applied stack, used to avoid redundant reloads. */
12+export const lastStackSignature = writable('');
13+
14+function apply(data: StackResponse) {
15+	if (!Array.isArray(data.changes)) return;
16+	changes.set(data.changes);
17+	mainChange.set(data.main ?? null);
18+	currentChange.set(data.current ?? null);
19+	lastStackSignature.set(stackSignature(data));
20+}
21+
22+export async function loadChanges(): Promise<StackResponse | null> {
23+	try {
24+		const res = await fetch('/api/changes');
25+		const data = (await res.json()) as StackResponse;
26+		apply(data);
27+		return data;
28+	} catch {
29+		return null;
30+	}
31+}
32+
33+async function postChange(action: 'select' | 'approve', changeId?: string): Promise<void> {
34+	const res = await fetch('/api/changes', {
35+		method: 'POST',
36+		headers: { 'Content-Type': 'application/json' },
37+		body: JSON.stringify({ action, changeId })
38+	});
39+	const data = (await res.json()) as StackResponse;
40+	if (data.error) throw new Error(data.error);
41+	apply(data);
42+	await loadFiles();
43+	await loadAllDiffs();
44+}
45+
46+export async function selectChange(changeId: string): Promise<void> {
47+	await postChange('select', changeId);
48+}
49+
50+export async function approveChange(changeId?: string): Promise<void> {
51+	await postChange('approve', changeId);
52+}
53+
54+/** Signature used to detect whether the stack changed enough to reload diffs. */
55+export function stackSignature(data: StackResponse | null): string {
56+	if (!data || !Array.isArray(data.changes)) return '';
57+	return JSON.stringify([
58+		data.main?.commitId ?? null,
59+		data.current ?? null,
60+		data.changes.map((c) => [c.changeId, c.commitId])
61+	]);
62+}
M src/lib/types/diff.ts
+19, -0
 1@@ -37,3 +37,22 @@ export interface DiffLine {
 2 export type DiffScope = 'uncommitted' | 'worktree';
 3 
 4 export type DiffMode = 'full' | 'unstaged';
 5+
 6+export interface ChangeInfo {
 7+	changeId: string;
 8+	commitId: string;
 9+	description: string;
10+	bookmarks: string[];
11+	isWorkingCopy: boolean;
12+	parents: string[];
13+}
14+
15+export interface ChangeStack {
16+	main: { changeId: string; commitId: string; description: string } | null;
17+	/** Effective change being viewed (selected change, or the working copy). */
18+	current: string | null;
19+	/** Explicitly selected change, or null when viewing the working copy. */
20+	selected: string | null;
21+	workingCopy: string | null;
22+	changes: ChangeInfo[];
23+}
M src/routes/+page.svelte
+49, -12
 1@@ -1,14 +1,17 @@
 2 <script lang="ts">
 3 	import { onMount } from 'svelte';
 4+	import { get } from 'svelte/store';
 5 	import FileTree from '$lib/components/FileTree.svelte';
 6+	import ChangeStack from '$lib/components/ChangeStack.svelte';
 7 	import DiffView from '$lib/components/DiffView.svelte';
 8 	import Toolbar from '$lib/components/Toolbar.svelte';
 9 	import CommitDialog from '$lib/components/CommitDialog.svelte';
10 	import ExportDialog from '$lib/components/ExportDialog.svelte';
11 	import TerminalPanel from '$lib/components/TerminalPanel.svelte';
12-	import { files, selectedFile, selectedDiff, selectedFileData, loadFiles, loadAllDiffs, loadRepoInfo, toggleApproval, diffScope, diffMode } from '$lib/stores/files.ts';
13+	import { files, selectedFile, selectedDiff, selectedFileData, loadFiles, loadAllDiffs, loadRepoInfo, toggleApproval, diffScope, diffMode, vcs } from '$lib/stores/files.ts';
14 	import { loadComments } from '$lib/stores/review.ts';
15-	import { sidebarOpen } from '$lib/stores/ui.ts';
16+	import { loadChanges, lastStackSignature } from '$lib/stores/changes.ts';
17+	import { sidebarOpen, commentingLine } from '$lib/stores/ui.ts';
18 	import { toggleTerminal } from '$lib/stores/terminal.ts';
19 
20 	let loading = $state(true);
21@@ -37,17 +40,48 @@
22 		window.addEventListener('mouseup', onMouseUp);
23 	}
24 
25-	onMount(async () => {
26-		try {
27-			await loadRepoInfo();
28+	onMount(() => {
29+		(async () => {
30+			try {
31+				await loadRepoInfo();
32+				await loadChanges();
33+				await loadFiles();
34+				await loadAllDiffs();
35+				await loadComments();
36+			} catch (err) {
37+				error = String(err);
38+			} finally {
39+				loading = false;
40+			}
41+		})();
42+
43+		// Track along with the session: poll for new changes and reload when the
44+		// stack or a commit actually changes. Skip while a comment is being edited.
45+		const refresh = async () => {
46+			if (get(vcs) !== 'jj') return;
47+			if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
48+			if (get(commentingLine)) return;
49+
50+			const before = get(lastStackSignature);
51+			await loadChanges();
52+			if (get(lastStackSignature) === before) return;
53 			await loadFiles();
54 			await loadAllDiffs();
55-			await loadComments();
56-		} catch (err) {
57-			error = String(err);
58-		} finally {
59-			loading = false;
60-		}
61+		};
62+
63+		const interval = setInterval(refresh, 5000);
64+		const onFocus = () => refresh();
65+		const onVisibility = () => {
66+			if (document.visibilityState === 'visible') refresh();
67+		};
68+		window.addEventListener('focus', onFocus);
69+		document.addEventListener('visibilitychange', onVisibility);
70+
71+		return () => {
72+			clearInterval(interval);
73+			window.removeEventListener('focus', onFocus);
74+			document.removeEventListener('visibilitychange', onVisibility);
75+		};
76 	});
77 
78 	function handleKeydown(e: KeyboardEvent) {
79@@ -84,7 +118,10 @@
80 		<!-- Sidebar -->
81 		{#if $sidebarOpen}
82 			<aside class="shrink-0 bg-panel/50 border-r border-border overflow-hidden flex flex-col relative" style="width: {sidebarWidth}px">
83-				<FileTree />
84+				<div class="flex-1 min-h-0 overflow-hidden">
85+					<FileTree />
86+				</div>
87+				<ChangeStack />
88 				<!-- Resize handle -->
89 				<!-- svelte-ignore a11y_no_static_element_interactions -->
90 				<div
A src/routes/api/changes/+server.ts
+34, -0
 1@@ -0,0 +1,34 @@
 2+import { json } from '@sveltejs/kit';
 3+import { getChangeStack, getVcsKind, selectChange, approveChange } from '$lib/server/vcs.ts';
 4+import type { RequestHandler } from './$types.ts';
 5+
 6+export const GET: RequestHandler = async () => {
 7+	try {
 8+		const [vcs, stack] = await Promise.all([getVcsKind(), getChangeStack()]);
 9+		return json({ vcs, ...stack });
10+	} catch (err) {
11+		return json({ error: String(err) }, { status: 500 });
12+	}
13+};
14+
15+export const POST: RequestHandler = async ({ request }) => {
16+	try {
17+		const body = await request.json();
18+		const { action, changeId } = body;
19+
20+		let stack;
21+		if (action === 'select') {
22+			if (!changeId) return json({ error: 'Missing changeId' }, { status: 400 });
23+			stack = await selectChange(changeId);
24+		} else if (action === 'approve') {
25+			stack = await approveChange(changeId);
26+		} else {
27+			return json({ error: 'Unknown action' }, { status: 400 });
28+		}
29+
30+		const vcs = await getVcsKind();
31+		return json({ vcs, ...stack });
32+	} catch (err) {
33+		return json({ error: String(err) }, { status: 400 });
34+	}
35+};
M src/routes/api/diff/+server.ts
+12, -6
 1@@ -1,5 +1,5 @@
 2 import { json } from '@sveltejs/kit';
 3-import { getDiffByMode, parseDiff, getFileContent, getOldRef, getRepoDir } from '$lib/server/vcs.ts';
 4+import { getDiffByMode, parseDiff, getFileContent, getOldRef, getNewRef, getRepoDir } from '$lib/server/vcs.ts';
 5 import type { DiffMode } from '$lib/types/index.ts';
 6 import type { DiffFile, DiffScope } from '$lib/types/index.ts';
 7 import { highlightLines } from '$lib/server/highlighter.ts';
 8@@ -7,15 +7,20 @@ import { readFile } from 'fs/promises';
 9 import { join } from 'path';
10 import type { RequestHandler } from './$types.ts';
11 
12-async function applyHighlighting(files: DiffFile[], oldRef: string = 'HEAD'): Promise<void> {
13+async function applyHighlighting(files: DiffFile[], oldRef: string = 'HEAD', newRef: string | null = null): Promise<void> {
14 	for (const file of files) {
15 		// Get the full new file content for accurate highlighting
16 		let newContent = '';
17 		let oldContent = '';
18 
19 		try {
20-			// Try reading the working tree version (new content)
21-			newContent = await readFile(join(getRepoDir(), file.path), 'utf-8');
22+			if (newRef) {
23+				// Viewing a specific revision (not the working tree)
24+				newContent = await getFileContent(file.path, newRef);
25+			} else {
26+				// Reading the working tree version (new content)
27+				newContent = await readFile(join(getRepoDir(), file.path), 'utf-8');
28+			}
29 		} catch {
30 			// File might be deleted
31 		}
32@@ -98,12 +103,13 @@ export const GET: RequestHandler = async ({ url }) => {
33 			}
34 		}
35 
36-		// Determine the old ref for syntax highlighting
37+		// Determine the old/new refs for syntax highlighting
38 		const oldRef = await getOldRef(scope);
39+		const newRef = await getNewRef();
40 
41 		// Skip highlighting for very large files
42 		const filesToHighlight = parsed.filter((f) => (f.totalLines || 0) <= MAX_LINES_FOR_HIGHLIGHTING);
43-		await applyHighlighting(filesToHighlight, oldRef);
44+		await applyHighlighting(filesToHighlight, oldRef, newRef);
45 
46 		return json({ files: parsed, raw });
47 	} catch (err) {