Skip to content

Commit f98d8c7

Browse files
oratisclaude
andauthored
feat(desktop): inspector expand panel (320px) — Plan/Context/Recent/Session (#91)
Implements the deferred inspector panel (design spec screen #3, HANDOFF §10). The right column is a 48px rail by default; the ‹ button or ⌘\ expands it to a 320px panel that squeezes the chat stream (via the .inspector-open grid modifier), not overlays it. Four sections: ▤ Plan — the agent's TodoWrite list + pending count ◐ Context — token usage bar (contextWindowFor(model) denominator) 📁 Recent files — files touched by Write/Edit/MultiEdit this conversation ⓘ Session info — project / path / model / mode / cumulative cost Empty sections show honest empty states (no fake placeholders). Wiring: ReplScreen lifts its inspector slice (usage/model/mode/recent files/ todos) to App via a single onInspector callback; App merges it into one InspectorData that feeds both the collapsed rail's badges and the expanded panel. The rail's ◐ context dot + ▤ plan badge are now driven by real data (previously hardcoded undefined). Taken over from the epic-neumann worktree and rebased onto main. Builds clean (vite 222 modules), typecheck + 24 desktop tests pass, format + 0 new lint warnings. NOTE: this is UI that still needs a visual pass in `pnpm --filter @deepcode/desktop tauri:dev` — confirm the 48↔320px toggle, ⌘\, and that all four sections render against the design spec. Logic/build are verified; pixels are not. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f93efa6 commit f98d8c7

6 files changed

Lines changed: 451 additions & 31 deletions

File tree

apps/desktop/src/App.tsx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
// Spec: docs/VISUAL_DESIGN.html
33
// Milestone: 0.1.2 — adds project-folder flow + inspector wiring + session refresh.
44

5-
import { useEffect, useState } from 'react';
5+
import { useCallback, useEffect, useState } from 'react';
6+
import { contextWindowFor } from '@deepcode/core/dist/providers/deepseek.js';
7+
import { InspectorPanel } from './components/InspectorPanel.js';
68
import { InspectorRail } from './components/InspectorRail.js';
79
import { ProjectPickerOverlay } from './components/ProjectPickerOverlay.js';
810
import { Sidebar } from './components/Sidebar.js';
@@ -23,6 +25,7 @@ import { SettingsScreen } from './screens/Settings.js';
2325
import { SkillsScreen } from './screens/Skills.js';
2426
import type { ScreenName } from './types/screens.js';
2527
import type { UpdateInfo } from './types/global.js';
28+
import { emptyInspectorData, type InspectorData } from './types/inspector.js';
2629

2730
export function App(): JSX.Element {
2831
const [hasKey, setHasKey] = useState<boolean | null>(null);
@@ -34,6 +37,15 @@ export function App(): JSX.Element {
3437
// Reconstructed messages for a resumed session; seeded into ReplScreen on its
3538
// next remount. Cleared when starting a fresh session.
3639
const [resumedMessages, setResumedMessages] = useState<Msg[] | undefined>(undefined);
40+
// Right inspector: 48 px rail by default, 320 px panel when expanded.
41+
const [inspectorExpanded, setInspectorExpanded] = useState(false);
42+
const [inspector, setInspector] = useState<InspectorData>(() => emptyInspectorData());
43+
44+
// Merge the slice ReplScreen lifts up (usage / model / mode / files / todos).
45+
// Stable identity so ReplScreen's sync effect doesn't refire every render.
46+
const handleInspector = useCallback((patch: Partial<InspectorData>) => {
47+
setInspector((prev) => ({ ...prev, ...patch }));
48+
}, []);
3749

3850
useEffect(() => {
3951
void window.deepcode.creds.load().then((c) => setHasKey(c.hasKey));
@@ -52,13 +64,15 @@ export function App(): JSX.Element {
5264
});
5365
const offComma = registerShortcut('meta+,', () => setScreen('settings'));
5466
const offSlash = registerShortcut('meta+/', () => setScreen('about'));
67+
const offBackslash = registerShortcut('meta+\\', () => setInspectorExpanded((v) => !v));
5568

5669
return () => {
5770
offShim();
5871
offReal();
5972
offN();
6073
offComma();
6174
offSlash();
75+
offBackslash();
6276
};
6377
}, []);
6478

@@ -95,9 +109,15 @@ export function App(): JSX.Element {
95109
return <ProjectPickerOverlay onPicked={handlePickProject} />;
96110
}
97111

98-
// Main shell: 3-column grid.
112+
// Main shell: 3-column grid. The right column is a 48 px rail by default and
113+
// a 320 px panel when expanded — the `inspector-open` modifier widens the
114+
// grid track so the panel squeezes the chat stream rather than overlaying it.
115+
const planCount = inspector.todos.filter((t) => t.status !== 'completed').length;
116+
const usedTokens = inspector.usage.inputTokens + inspector.usage.outputTokens;
117+
const contextFill = usedTokens > 0 ? usedTokens / contextWindowFor(inspector.model) : undefined;
118+
99119
return (
100-
<div className="app-shell">
120+
<div className={'app-shell' + (inspectorExpanded ? ' inspector-open' : '')}>
101121
{update && <UpdateBanner info={update} />}
102122
<Sidebar
103123
key={`sb-${sessionEpoch}`}
@@ -141,10 +161,25 @@ export function App(): JSX.Element {
141161
setScreen,
142162
projectPath,
143163
() => setSessionEpoch((k) => k + 1),
164+
handleInspector,
144165
resumedMessages,
145166
)}
146167
</main>
147-
<InspectorRail activeScreen={screen} onChange={(s) => setScreen(s)} contextFill={undefined} />
168+
{inspectorExpanded ? (
169+
<InspectorPanel
170+
projectPath={projectPath}
171+
data={inspector}
172+
onCollapse={() => setInspectorExpanded(false)}
173+
/>
174+
) : (
175+
<InspectorRail
176+
activeScreen={screen}
177+
onChange={(s) => setScreen(s)}
178+
onExpand={() => setInspectorExpanded(true)}
179+
planCount={planCount}
180+
contextFill={contextFill}
181+
/>
182+
)}
148183
</div>
149184
);
150185
}
@@ -154,6 +189,7 @@ function renderScreen(
154189
setScreen: (s: ScreenName) => void,
155190
projectPath: string,
156191
onTurnComplete: () => void,
192+
onInspector: (patch: Partial<InspectorData>) => void,
157193
initialMessages?: Msg[],
158194
): JSX.Element {
159195
switch (screen) {
@@ -164,6 +200,7 @@ function renderScreen(
164200
projectPath={projectPath}
165201
onTurnComplete={onTurnComplete}
166202
initialMessages={initialMessages}
203+
onInspector={onInspector}
167204
/>
168205
);
169206
case 'sessions':
@@ -187,6 +224,7 @@ function renderScreen(
187224
projectPath={projectPath}
188225
onTurnComplete={onTurnComplete}
189226
initialMessages={initialMessages}
227+
onInspector={onInspector}
190228
/>
191229
);
192230
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Right-column expanded inspector (320 px).
2+
// Design spec screen #3 — the panel that the 48 px rail expands into when the
3+
// user clicks ‹ or presses ⌘\. Four sections, top to bottom:
4+
// ▤ Plan — the agent's TodoWrite list, with a pending count
5+
// ◐ Context — token usage, same bar as the composer's .ctx-bar
6+
// 📁 Recent files — files touched by Write/Edit this conversation
7+
// ⓘ Session info — project / path / model / mode / cost
8+
//
9+
// All data comes from the InspectorData the parent (App) maintains; this
10+
// component is purely presentational. Sections with no data show an honest
11+
// empty state rather than a placeholder — per HANDOFF: no fake sections.
12+
13+
import { contextWindowFor } from '@deepcode/core/dist/providers/deepseek.js';
14+
import { projectName } from '../lib/project.js';
15+
import type { InspectorData } from '../types/inspector.js';
16+
17+
interface InspectorPanelProps {
18+
projectPath: string;
19+
data: InspectorData;
20+
/** Collapse back to the 48 px rail (the › button / ⌘\). */
21+
onCollapse: () => void;
22+
}
23+
24+
const MODE_LABELS: Record<string, string> = {
25+
default: 'Default · ask',
26+
acceptEdits: 'Accept edits',
27+
plan: 'Plan mode',
28+
dontAsk: "Don't ask",
29+
bypassPermissions: 'Bypass',
30+
};
31+
32+
export function InspectorPanel({
33+
projectPath,
34+
data,
35+
onCollapse,
36+
}: InspectorPanelProps): JSX.Element {
37+
const { usage, costYuan, model, mode, recentFiles, todos } = data;
38+
39+
const contextWindow = contextWindowFor(model);
40+
const usedTokens = usage.inputTokens + usage.outputTokens;
41+
const fillPct = Math.min(100, (usedTokens / contextWindow) * 100);
42+
43+
const pending = todos.filter((t) => t.status !== 'completed').length;
44+
45+
return (
46+
<aside className="inspector">
47+
<div className="inspector-head">
48+
<span className="inspector-title">Inspector</span>
49+
<button
50+
type="button"
51+
className="rail-btn"
52+
title="Collapse inspector (⌘\\)"
53+
onClick={onCollapse}
54+
>
55+
56+
</button>
57+
</div>
58+
59+
{/* ── ▤ Plan ─────────────────────────────────────────────── */}
60+
<h5>▤ Plan{pending > 0 ? ` · ${pending} pending` : ''}</h5>
61+
{todos.length === 0 ? (
62+
<p className="insp-empty">No plan yet — the agent hasn’t written a todo list.</p>
63+
) : (
64+
<div className="todo-list">
65+
{todos.map((t, i) => (
66+
<div
67+
key={i}
68+
className={
69+
'todo-item' +
70+
(t.status === 'completed' ? ' done' : t.status === 'in_progress' ? ' active' : '')
71+
}
72+
>
73+
<span className="check" />
74+
<span className="label">{t.status === 'in_progress' ? t.activeForm : t.content}</span>
75+
</div>
76+
))}
77+
</div>
78+
)}
79+
80+
{/* ── ◐ Context ──────────────────────────────────────────── */}
81+
<h5>◐ Context</h5>
82+
<div className="ctx-bar">
83+
<span>
84+
{usedTokens.toLocaleString()} / {contextWindow.toLocaleString()}
85+
</span>
86+
<div className="progress">
87+
<div className="fill" style={{ width: `${fillPct}%` }} />
88+
</div>
89+
<span>{fillPct.toFixed(1)}%</span>
90+
</div>
91+
92+
{/* ── 📁 Recent files ────────────────────────────────────── */}
93+
<h5>📁 Recent files</h5>
94+
{recentFiles.length === 0 ? (
95+
<p className="insp-empty">No files written or edited yet.</p>
96+
) : (
97+
<div className="recent-files">
98+
{recentFiles.map((f) => (
99+
<div className="recent-file" key={f} title={f}>
100+
<span className="name">{basename(f)}</span>
101+
<span className="dir">{dirname(f)}</span>
102+
</div>
103+
))}
104+
</div>
105+
)}
106+
107+
{/* ── ⓘ Session info ─────────────────────────────────────── */}
108+
<h5>ⓘ Session info</h5>
109+
<div className="insp-row">
110+
<span className="k">Project</span>
111+
<span className="v">{projectName(projectPath)}</span>
112+
</div>
113+
<div className="insp-row">
114+
<span className="k">Path</span>
115+
<span className="v" title={projectPath}>
116+
{abbreviatePath(projectPath)}
117+
</span>
118+
</div>
119+
<div className="insp-row">
120+
<span className="k">Model</span>
121+
<span className="v">{model}</span>
122+
</div>
123+
<div className="insp-row">
124+
<span className="k">Mode</span>
125+
<span className="v">{MODE_LABELS[mode] ?? mode}</span>
126+
</div>
127+
<div className="insp-row">
128+
<span className="k">Spend</span>
129+
<span className="v">¥ {costYuan.toFixed(4)}</span>
130+
</div>
131+
</aside>
132+
);
133+
}
134+
135+
// ─── path helpers ─────────────────────────────────────────────────────
136+
137+
function basename(p: string): string {
138+
const parts = p.split('/').filter(Boolean);
139+
return parts[parts.length - 1] ?? p;
140+
}
141+
142+
function dirname(p: string): string {
143+
const idx = p.lastIndexOf('/');
144+
if (idx <= 0) return '';
145+
return abbreviatePath(p.slice(0, idx));
146+
}
147+
148+
/** Abbreviate a long path by replacing the $HOME prefix with "~". */
149+
function abbreviatePath(p: string): string {
150+
const m = p.match(/^\/Users\/[^/]+/);
151+
if (m) return '~' + p.slice(m[0].length);
152+
return p;
153+
}

apps/desktop/src/components/InspectorRail.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
//
44
// Each rail button routes to a screen so users can reach Plan / Files /
55
// Info / Settings without scrolling for a hidden menu. The ‹ expand
6-
// chevron is still deferred (the full-width inspector panel lands in
7-
// the next phase) — we leave it disabled with a tooltip.
6+
// chevron opens the full-width inspector panel (InspectorPanel) — App
7+
// owns the collapsed↔expanded state and passes it down via onExpand.
88

99
import type { ScreenName } from '../types/screens.js';
1010

@@ -17,13 +17,16 @@ interface InspectorRailProps {
1717
activeScreen: ScreenName;
1818
/** Switch screen. */
1919
onChange: (screen: ScreenName) => void;
20+
/** Expand the rail into the 320 px inspector panel (‹ / ⌘\). */
21+
onExpand: () => void;
2022
}
2123

2224
export function InspectorRail({
2325
planCount,
2426
contextFill,
2527
activeScreen,
2628
onChange,
29+
onExpand,
2730
}: InspectorRailProps): JSX.Element {
2831
const ctxColor =
2932
contextFill === undefined
@@ -38,9 +41,9 @@ export function InspectorRail({
3841
<aside className="inspector-rail">
3942
<button
4043
type="button"
41-
className="rail-btn"
42-
title="Expand inspector (⌘\\) — coming in next phase"
43-
disabled
44+
className="rail-btn expand"
45+
title="Expand inspector (⌘\\)"
46+
onClick={onExpand}
4447
>
4548
4649
</button>

0 commit comments

Comments
 (0)