Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/desktop/e2e/desktop-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
52 changes: 48 additions & 4 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -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);
},
Expand Down Expand Up @@ -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 (
<div className={shellClass}>
Expand Down Expand Up @@ -259,7 +289,18 @@ export function App(): JSX.Element {
openFile,
)}
</main>
{filesVisible ? (
{changesShowing ? (
<ChangesPanel
state={changes.state}
width={fp.state.width}
onRefresh={() => void changes.refresh()}
onToggleFile={changes.toggleFile}
onApply={(findings) => void changes.apply(findings)}
onRevert={(actionId) => void changes.revert(actionId)}
onOpenFile={openFile}
onResizeStart={onFilePanelResizeStart}
/>
) : filesVisible ? (
<FilePanel
tabs={fp.state.tabs}
activeIndex={fp.state.activeIndex}
Expand All @@ -285,11 +326,14 @@ export function App(): JSX.Element {
<InspectorRail
inspectorActive={inspectorShowing}
filesActive={filesVisible}
changesActive={changesShowing}
settingsActive={SETTINGS_FAMILY.includes(screen)}
planCount={planCount}
contextFill={contextFill}
changesCount={changesBadge(changes.state)}
onToggleInspector={toggleInspector}
onToggleFiles={toggleFiles}
onToggleChanges={toggleChanges}
onSettings={() => setScreen('settings')}
/>
</div>
Expand Down
206 changes: 206 additions & 0 deletions apps/desktop/src/components/ChangesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside className="changes-panel" style={{ width: `${width}px` }} data-testid="changes-panel">
<div className="fp-resize" onMouseDown={onResizeStart} title="Drag to resize" />

<div className="ch-head">
<span className="ch-title">Changes</span>
<button type="button" className="ch-btn" onClick={onRefresh} disabled={state.loading}>
{state.loading ? 'Refreshing…' : 'Refresh'}
</button>
</div>

{state.error && <div className="ch-error">{state.error}</div>}

{state.findings.length > 0 && (
<section className="ch-section">
<div className="ch-section-head">
<h5>
Review findings
{pending.length > 0 ? ` · ${pending.length} to apply` : ' · all applied'}
</h5>
{pending.length > 1 && (
<button type="button" className="ch-btn" onClick={() => onApply(pending)}>
Apply all
</button>
)}
</div>
{state.findings.map((f) => {
const action = appliedAction(state, f.findingId);
const busy = state.applying.includes(f.findingId);
return (
<div className="ch-finding" key={f.findingId}>
<div className="ch-finding-head">
<span className={`ch-prio p${f.priority}`}>
{PRIORITY_LABEL[f.priority] ?? 'note'}
</span>
<button
type="button"
className="ch-path"
title={`Open ${f.path}`}
onClick={() => onOpenFile?.(f.path)}
>
{f.path}
{f.startLine > 0 ? `:${f.startLine}` : ''}
</button>
</div>
<div className="ch-finding-title">{f.title}</div>
{f.body && <div className="ch-finding-body">{f.body}</div>}
<div className="ch-finding-actions">
{action ? (
<>
<span className="ch-applied">applied</span>
<button
type="button"
className="ch-btn"
onClick={() => onRevert(action.actionId)}
>
Revert
</button>
</>
) : (
<button
type="button"
className="ch-btn primary"
disabled={busy || !f.replacement}
title={
f.replacement
? 'Apply this finding as a normal, permission-gated turn'
: 'This finding has no suggested replacement to apply'
}
onClick={() => onApply([f])}
>
{busy ? 'Applying…' : 'Apply'}
</button>
)}
</div>
</div>
);
})}
</section>
)}

<section className="ch-section">
<h5>Working tree</h5>
{!state.repository ? (
<p className="ch-empty">Not a Git repository.</p>
) : state.files === null ? (
<p className="ch-empty">{state.loading ? 'Reading…' : 'Not loaded yet.'}</p>
) : state.files.length === 0 ? (
<p className="ch-empty">Working tree clean.</p>
) : (
state.files.map((f) => (
<FileRow
key={f.path}
file={f}
expanded={state.expanded.includes(f.path)}
onToggle={() => onToggleFile(f.path)}
onOpen={onOpenFile ? () => onOpenFile(f.path) : undefined}
/>
))
)}
{state.diffTruncated && (
<p className="ch-empty">Diff truncated — the change set exceeds the cap.</p>
)}
</section>
</aside>
);
}

function FileRow({
file,
expanded,
onToggle,
onOpen,
}: {
file: ChangedFile;
expanded: boolean;
onToggle: () => void;
onOpen?: () => void;
}): JSX.Element {
return (
<div className="ch-file">
<div className="ch-file-head">
<button type="button" className="ch-disclose" onClick={onToggle} aria-expanded={expanded}>
{expanded ? '▾' : '▸'}
</button>
<button
type="button"
className="ch-path"
onClick={onOpen ?? onToggle}
title={onOpen ? `Open ${file.path}` : file.path}
>
{file.path}
</button>
<span className="ch-status">{file.status}</span>
<span className="ch-stat add">+{file.additions}</span>
<span className="ch-stat del">-{file.deletions}</span>
</div>
{expanded && (
<div className="ch-hunks">
{file.binary ? (
<div className="ch-empty">Binary file — no textual diff.</div>
) : file.hunks.length === 0 ? (
<div className="ch-empty">No hunks returned.</div>
) : (
file.hunks.map((h) => (
<div className="ch-hunk" key={h.header}>
<div className="ch-hunk-head">{h.header}</div>
{h.lines.map((l, i) => (
<div className={`ch-line ${l.kind}`} key={`${h.header}-${i}`}>
<span className="ch-no">{l.oldLine ?? ''}</span>
<span className="ch-no">{l.newLine ?? ''}</span>
<span className="ch-sign">
{l.kind === 'addition' ? '+' : l.kind === 'deletion' ? '-' : ' '}
</span>
<span className="ch-text">{l.text}</span>
</div>
))}
</div>
))
)}
{file.truncated && <div className="ch-empty">File diff truncated.</div>}
</div>
)}
</div>
);
}
Loading
Loading