diff --git a/apps/desktop/e2e/desktop-preview.spec.ts b/apps/desktop/e2e/desktop-preview.spec.ts
index 9cb92c7..1cdde36 100644
--- a/apps/desktop/e2e/desktop-preview.spec.ts
+++ b/apps/desktop/e2e/desktop-preview.spec.ts
@@ -129,3 +129,41 @@ test('runs slash commands in the composer instead of sending them to the model',
await expect(palette).toBeHidden();
await expect(composer).toHaveValue('/he');
});
+
+test('reviews findings and the working tree from the Changes panel', async ({ page }) => {
+ // Run a turn so the fixture emits a review finding.
+ const composer = page.getByPlaceholder(composerPlaceholder, { exact: true });
+ await composer.fill('Add a boss phase');
+ await composer.press('Enter');
+ const approve = page.getByRole('button', { name: /^Approve \(↵\)$/ });
+ await expect(approve).toBeVisible();
+ await approve.click();
+ await expect(approve).toBeHidden();
+
+ await page.getByRole('button', { name: /^Changes\b/ }).click();
+ const panel = page.getByTestId('changes-panel');
+ await expect(panel).toBeVisible();
+
+ // Findings section: the agent's finding, with its file and priority.
+ await expect(panel.getByText('Boss phase is read before it is validated')).toBeVisible();
+ await expect(panel.getByRole('button', { name: 'src/boss.ts:13' })).toBeVisible();
+ await expect(panel.getByText('high', { exact: true })).toBeVisible();
+
+ // Working tree: files with counts, collapsed until asked for.
+ await expect(panel.getByRole('button', { name: 'src/boss.ts', exact: true })).toBeVisible();
+ await expect(panel.getByText('+2', { exact: true })).toBeVisible();
+ await expect(panel.locator('.ch-line')).toHaveCount(0);
+ await panel.locator('.ch-disclose').first().click();
+ await expect(panel.locator('.ch-line.addition')).toHaveCount(2);
+ await expect(panel.locator('.ch-line.deletion')).toHaveCount(1);
+
+ // Binary files say so rather than rendering nothing.
+ await panel.locator('.ch-disclose').last().click();
+ await expect(panel.getByText('Binary file — no textual diff.')).toBeVisible();
+
+ // Applying runs a real turn; the fixture answers with a review_action, and
+ // the row flips to applied + Revert.
+ await panel.getByRole('button', { name: 'Apply', exact: true }).click();
+ await expect(panel.getByText('applied', { exact: true })).toBeVisible();
+ await expect(panel.getByRole('button', { name: 'Revert', exact: true })).toBeVisible();
+});
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index 80327ea..495384b 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -4,6 +4,7 @@
import { useCallback, useEffect, useState, type JSX } from 'react';
import { contextWindowFor } from '@deepcode/core/dist/providers/model-metadata.js';
+import { ChangesPanel } from './components/ChangesPanel.js';
import { FilePanel } from './components/FilePanel.js';
import { InspectorPanel } from './components/InspectorPanel.js';
import { InspectorRail } from './components/InspectorRail.js';
@@ -16,6 +17,8 @@ import { clearProtocolThread as clearAgentHistory } from './lib/protocol-agent.j
import { loadProjectPath, saveProjectPath } from './lib/project.js';
import { storedToMsgs, type Msg } from './lib/repl-stream.js';
import { onUpdateDownloaded, startUpdaterPolling } from './lib/updater.js';
+import { changesBadge } from './lib/changes-reducer.js';
+import { useChanges } from './lib/use-changes.js';
import { useFilePanel } from './lib/use-file-panel.js';
import { AboutScreen } from './screens/About.js';
import { MCPManagerScreen } from './screens/MCPManager.js';
@@ -54,6 +57,9 @@ export function App(): JSX.Element {
// Right-side file panel (§3.11): opens to the left of the rail.
const fp = useFilePanel();
const [filesCollapsed, setFilesCollapsed] = useState(false);
+ // Changes panel (§ review): findings + working-tree diff, same right slot.
+ const changes = useChanges();
+ const [changesOpen, setChangesOpen] = useState(false);
// Drag the panel's left edge to resize (320–800px, persisted by the hook).
const onFilePanelResizeStart = useCallback(
@@ -78,20 +84,34 @@ export function App(): JSX.Element {
const toggleInspector = useCallback(() => {
setFilesCollapsed(true); // a visible inspector hides the file panel
+ setChangesOpen(false);
setInspectorOpen((v) => !v);
}, []);
const toggleFiles = useCallback(() => {
setInspectorOpen(false);
+ setChangesOpen(false);
if (fp.isOpen) setFilesCollapsed((c) => !c);
else void fp.openViaPicker(); // no tabs yet — let the user pick a file
}, [fp.isOpen, fp.openViaPicker]);
+ const toggleChanges = useCallback(() => {
+ setInspectorOpen(false);
+ setFilesCollapsed(true);
+ setChangesOpen((open) => {
+ // Load on open rather than on mount: the diff is a Git call, and an
+ // unopened panel shouldn't pay for it.
+ if (!open) void changes.refresh();
+ return !open;
+ });
+ }, [changes.refresh]);
+
// Open a specific file (chat tool card / inspector recent files): surface the
// file panel and step the inspector aside for it.
const openFile = useCallback(
(path: string) => {
setInspectorOpen(false);
+ setChangesOpen(false);
setFilesCollapsed(false);
void fp.open(path);
},
@@ -184,12 +204,22 @@ export function App(): JSX.Element {
// The rail is always the last 64px column. A panel (file OR inspector) opens
// to its left, widening the grid so it squeezes chat rather than overlaying.
- const inspectorShowing = inspectorOpen && !filesVisible;
+ const changesShowing = changesOpen && !filesVisible;
+ const inspectorShowing = inspectorOpen && !filesVisible && !changesShowing;
+ // The Changes panel reuses the file panel's wider grid track — it shows diff
+ // hunks and needs the same room.
const shellClass =
- 'app-shell' + (filesVisible ? ' file-open' : inspectorShowing ? ' inspector-open' : '');
+ 'app-shell' +
+ (filesVisible || changesShowing ? ' file-open' : inspectorShowing ? ' inspector-open' : '');
// Name of the right-side panel currently showing — surfaced in the otherwise
// empty macOS titlebar strip so the rail toggles gain a visible, labeled echo.
- const activePanelName = filesVisible ? 'Files' : inspectorShowing ? 'Inspector' : null;
+ const activePanelName = filesVisible
+ ? 'Files'
+ : changesShowing
+ ? 'Changes'
+ : inspectorShowing
+ ? 'Inspector'
+ : null;
return (
@@ -259,7 +289,18 @@ export function App(): JSX.Element {
openFile,
)}
- {filesVisible ? (
+ {changesShowing ? (
+ void changes.refresh()}
+ onToggleFile={changes.toggleFile}
+ onApply={(findings) => void changes.apply(findings)}
+ onRevert={(actionId) => void changes.revert(actionId)}
+ onOpenFile={openFile}
+ onResizeStart={onFilePanelResizeStart}
+ />
+ ) : filesVisible ? (
setScreen('settings')}
/>
diff --git a/apps/desktop/src/components/ChangesPanel.tsx b/apps/desktop/src/components/ChangesPanel.tsx
new file mode 100644
index 0000000..f4425fb
--- /dev/null
+++ b/apps/desktop/src/components/ChangesPanel.tsx
@@ -0,0 +1,206 @@
+// Right-side Changes panel — review findings on top, working-tree diff below.
+//
+// Presentational: the parent (useChanges) owns fetching, the protocol calls and
+// the reducer. Every interaction is a callback so the panel is previewable and
+// testable without a backend.
+
+import type { JSX } from 'react';
+import {
+ appliedAction,
+ pendingFindings,
+ type ChangedFile,
+ type ChangesState,
+ type ReviewFinding,
+} from '../lib/changes-reducer.js';
+
+interface ChangesPanelProps {
+ state: ChangesState;
+ width: number;
+ onRefresh: () => void;
+ onToggleFile: (path: string) => void;
+ onApply: (findings: ReviewFinding[]) => void;
+ onRevert: (actionId: string) => void;
+ onOpenFile?: (path: string) => void;
+ onResizeStart: (e: React.MouseEvent) => void;
+}
+
+const PRIORITY_LABEL = ['blocker', 'high', 'medium', 'low'] as const;
+
+export function ChangesPanel({
+ state,
+ width,
+ onRefresh,
+ onToggleFile,
+ onApply,
+ onRevert,
+ onOpenFile,
+ onResizeStart,
+}: ChangesPanelProps): JSX.Element {
+ const pending = pendingFindings(state);
+
+ return (
+
+ );
+}
+
+function FileRow({
+ file,
+ expanded,
+ onToggle,
+ onOpen,
+}: {
+ file: ChangedFile;
+ expanded: boolean;
+ onToggle: () => void;
+ onOpen?: () => void;
+}): JSX.Element {
+ return (
+
+
+
+
+ {file.status}
+ +{file.additions}
+ -{file.deletions}
+
+ {expanded && (
+
+ {file.binary ? (
+
Binary file — no textual diff.
+ ) : file.hunks.length === 0 ? (
+
No hunks returned.
+ ) : (
+ file.hunks.map((h) => (
+
+
{h.header}
+ {h.lines.map((l, i) => (
+
+ {l.oldLine ?? ''}
+ {l.newLine ?? ''}
+
+ {l.kind === 'addition' ? '+' : l.kind === 'deletion' ? '-' : ' '}
+
+ {l.text}
+
+ ))}
+
+ ))
+ )}
+ {file.truncated &&
File diff truncated.
}
+
+ )}
+
+ );
+}
diff --git a/apps/desktop/src/components/InspectorRail.tsx b/apps/desktop/src/components/InspectorRail.tsx
index 3fc939a..16f8f75 100644
--- a/apps/desktop/src/components/InspectorRail.tsx
+++ b/apps/desktop/src/components/InspectorRail.tsx
@@ -4,6 +4,7 @@
// model), so it's no longer "everything opens the inspector":
// • ⓘ Inspector — plan / context / recent files / session (toggles the panel)
// • ▤ Files — the file preview panel (Source / Diff / History)
+// • ⑂ Changes — review findings + the working-tree diff (apply / revert)
// • ⚙ Settings — the Settings shell (a main-area screen, not a right panel)
// The active panel's icon is highlighted; clicking it again closes the panel.
@@ -14,25 +15,33 @@ interface InspectorRailProps {
inspectorActive: boolean;
/** File preview panel is the visible right panel. */
filesActive: boolean;
+ /** Changes panel is the visible right panel. */
+ changesActive: boolean;
/** On any settings-family screen (highlights the cog). */
settingsActive: boolean;
/** Plan items pending — badge on the Inspector icon. */
planCount?: number;
/** Context fill 0..1 — tints the Inspector icon (warn ≥ 0.8). */
contextFill?: number;
+ /** Findings awaiting action, else changed files — badge on the Changes icon. */
+ changesCount?: number;
onToggleInspector: () => void;
onToggleFiles: () => void;
+ onToggleChanges: () => void;
onSettings: () => void;
}
export function InspectorRail({
inspectorActive,
filesActive,
+ changesActive,
settingsActive,
planCount,
contextFill,
+ changesCount,
onToggleInspector,
onToggleFiles,
+ onToggleChanges,
onSettings,
}: InspectorRailProps): JSX.Element {
const ctxColor =
@@ -70,6 +79,19 @@ export function InspectorRail({
Files
+
+