From 1cee6ee5ff61df2809f8b4bf8ee6bcc94d98487d Mon Sep 17 00:00:00 2001 From: t Date: Mon, 3 Aug 2026 08:58:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(desktop):=20Changes=20panel=20?= =?UTF-8?q?=E2=80=94=20review=20findings=20and=20the=20working=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The protocol has carried workspace/diff and review apply/revert since 0.2.0, protocol-agent.ts exposed diff()/applyFindings()/revertAction(), and the desktop called none of them: an engine with no steering wheel. Finding C / F7 in docs/THREE_WAY_REVIEW.md. A fourth rail icon opens a Changes panel with two sections: Review findings — every review_finding the agent submits, with priority, file and line, and per-finding Apply (or Apply all). Applying runs review/apply as an ordinary turn, so it keeps permissions, approvals, hooks, sandboxing and snapshots; the resulting review_action flips the row to applied and offers Revert. Findings with no suggested replacement show a disabled Apply that says why, rather than failing on click. Working tree — files from workspace/diff with add/delete counts, collapsed until asked for, then hunks rendered line by line. Binary and truncated files say so instead of rendering an empty box. The badge shows findings still to act on, falling back to the changed-file count. Fixes a real bug found by becoming the first consumer of these events. The bus envelope was built as `{ kind: 'event', ..., ...payload }` — and a review_action payload carries its own `kind` ('apply' | 'revert'), so the spread overwrote the envelope discriminator and every consumer filtering on `kind === 'event'` silently dropped the event. The payload is now nested. The existing projection test passed straight through this because it only checked fields the spread preserved. The preview fixture gains workspace/diff, review/apply and a review_finding so the Playwright journey exercises the panel for real. Co-Authored-By: Claude Opus 5 --- apps/desktop/e2e/desktop-preview.spec.ts | 36 +++ apps/desktop/src/App.tsx | 52 +++- apps/desktop/src/components/ChangesPanel.tsx | 206 ++++++++++++++++ apps/desktop/src/components/InspectorRail.tsx | 45 ++++ apps/desktop/src/index.css | 223 ++++++++++++++++++ apps/desktop/src/lib/changes-reducer.test.ts | 193 +++++++++++++++ apps/desktop/src/lib/changes-reducer.ts | 168 +++++++++++++ apps/desktop/src/lib/protocol-agent.test.ts | 15 +- apps/desktop/src/lib/protocol-agent.ts | 25 +- apps/desktop/src/lib/use-changes.ts | 107 +++++++++ apps/desktop/src/preview-app.tsx | 93 ++++++++ 11 files changed, 1147 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/components/ChangesPanel.tsx create mode 100644 apps/desktop/src/lib/changes-reducer.test.ts create mode 100644 apps/desktop/src/lib/changes-reducer.ts create mode 100644 apps/desktop/src/lib/use-changes.ts diff --git a/apps/desktop/e2e/desktop-preview.spec.ts b/apps/desktop/e2e/desktop-preview.spec.ts index 9cb92c7..15aef56 100644 --- a/apps/desktop/e2e/desktop-preview.spec.ts +++ b/apps/desktop/e2e/desktop-preview.spec.ts @@ -128,4 +128,40 @@ test('runs slash commands in the composer instead of sending them to the model', await composer.press('Escape'); 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 + +