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 + +