Skip to content

Commit 006d420

Browse files
bloveclaude
andauthored
Code mode: file tree with responsive collapse (#570)
* docs(spec): code-mode file tree (VS Code style, lg+ responsive) Adds a file tree on the left of the Code-mode tab strip at lg+, with all files pre-opened as tabs, tree-click activates existing tab or opens new, close X on tabs, no cross-capability persistence, collapse persisted via localStorage. No new deps; pure presentational FileTree + state moved into CodeMode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(plan): code-mode file tree implementation plan 8 tasks: land prerequisite prose/padding work, buildTree utility (TDD), CodeMode state migration to openPaths+activePath, FileTree component (TDD), lg: responsive split + tree-click integration, tab close (×) + last-tab empty state, collapse toggle with localStorage, full verification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cockpit): unified prose width modifiers + code-mode padding wrapper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(cockpit): add buildTree utility for Code-mode file tree Pure function that converts flat file paths into a compact tree with VS-Code-style single-child folder merging and namespace-prefix peeling. All 6 unit tests green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cockpit): tighten buildTree test assertions and normalize path slashes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(cockpit): controlled openPaths + activePath state in CodeMode Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(cockpit): FileTree component with folder collapse and active row Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cockpit): file row click target + ellipsis label + drop role=tree Wrap file label in <span data-file-label> for text-overflow to work on flex children; move chip inside the button so clicking it fires onSelect; remove role="tree" from the outer <ul> (no treeitem/group/arrow-key support). Update both spec assertions to query [data-file-label]. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(cockpit): mount FileTree in Code mode with lg: responsive split Places the FileTree to the left of the tab strip at lg:+ widths, hidden below. Wires onSelect to openPaths/activePath handlers so tree-clicks activate existing tabs or open and activate new ones. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cockpit): file-tree aside opacity + remove dead file-row CSS rule * feat(cockpit): tab close button + last-tab empty state in Code mode Adds × close affordance on each Code-mode tab (visible on hover / active tab). Closing a tab removes it from openPaths, activates the left neighbor (or new leftmost if first was closed), and shows a "Select a file from the tree to begin." empty state when all tabs are closed. TDD: 2 new passing tests; 6 pre-existing tests updated to strip × from textContent comparisons. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cockpit): gate Tabs on open count + keyboard-accessible tab close - Render <Tabs> only when openPaths.length > 0 and activePath !== null; show empty-state as the exclusive alternative (eliminates Radix controlled→uncontrolled warning on last-tab-close). - value={activePath} without ?? undefined inside the guarded branch. - Add tabIndex={0} and onKeyDown (Enter/Space) to close <span> for a11y. - Remove unreachable ?? next[0] fallback in handleClose. - Hoist getTabLabel(path) to a single const per map iteration. - Add keyboard-close test (9 total in code-mode.spec.tsx). * feat(cockpit): persistent collapse toggle for Code-mode file tree Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cockpit): suppress collapse animation on hydration + flex layout for smooth transition Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a3a9420 commit 006d420

10 files changed

Lines changed: 2011 additions & 48 deletions

File tree

apps/cockpit/src/app/cockpit.css

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,16 @@ pre.shiki {
205205
font-size: 0.75rem;
206206
}
207207

208-
/* Shared prose layer — docs + api */
208+
/* Shared prose layer — docs + api + code mode content */
209209
.cockpit-prose {
210210
max-width: 42rem;
211+
margin-inline: auto;
211212
font-size: 0.9rem;
212213
line-height: 1.7;
213214
color: var(--ds-text-secondary);
214215
}
216+
.cockpit-prose--wide { max-width: 48rem; }
217+
.cockpit-prose--code { max-width: 56rem; }
215218
.cockpit-prose h1, .cockpit-prose h2, .cockpit-prose h3 {
216219
font-family: var(--font-garamond), var(--ds-font-serif);
217220
color: var(--ds-text-primary);
@@ -231,3 +234,41 @@ pre.shiki {
231234
.cockpit-prose table.params { border-collapse: collapse; margin: 0.5rem 0; }
232235
.cockpit-prose table.params th { font-family: var(--font-mono), monospace; font-size: 0.6rem; letter-spacing: 0.06em; text-transform: uppercase; padding-bottom: 0.5rem; border-bottom: 1px solid var(--ds-border); }
233236
.cockpit-prose table.params td { padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid var(--ds-border); }
237+
238+
/* Code-mode file tree */
239+
.cockpit-file-tree { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.7; }
240+
.cockpit-file-tree ul { list-style: none; padding: 0; margin: 0; }
241+
.cockpit-file-tree__file,
242+
.cockpit-file-tree__folder {
243+
display: flex; align-items: center; gap: 0.4rem; flex: 1; min-width: 0;
244+
padding: 3px 0.75rem 3px 0.75rem; background: transparent; border: 0; text-align: left; cursor: pointer;
245+
color: var(--ds-text-secondary); font-family: var(--font-mono), "JetBrains Mono", monospace; font-size: 12px;
246+
border-left: 2px solid transparent; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
247+
}
248+
.cockpit-file-tree__folder { color: var(--ds-text-muted); display: flex; align-items: center; }
249+
.cockpit-file-tree__caret { font-size: 9px; color: var(--ds-text-muted); width: 0.65rem; flex-shrink: 0; }
250+
.cockpit-file-tree__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
251+
.cockpit-file-tree__chip {
252+
font-family: var(--font-mono), monospace; font-size: 9px;
253+
padding: 1px 5px; border-radius: 3px; margin-right: 0.5rem; flex-shrink: 0;
254+
background: var(--ds-accent-surface); color: var(--ds-accent);
255+
opacity: 0.85;
256+
}
257+
.cockpit-file-tree__file:hover { color: var(--ds-text-primary); }
258+
.cockpit-file-tree__file[aria-current="true"] {
259+
background: var(--ds-accent-surface);
260+
color: var(--ds-text-primary);
261+
border-left-color: var(--ds-accent);
262+
}
263+
264+
/* Tab close (×) on Code-mode tabs */
265+
.cockpit-tab-trigger { display: inline-flex; align-items: center; gap: 0.4rem; }
266+
.cockpit-tab-trigger__close {
267+
display: inline-flex; align-items: center; justify-content: center;
268+
width: 0.95rem; height: 0.95rem; border-radius: 0.2rem;
269+
color: var(--ds-text-muted); font-size: 0.85rem; line-height: 1;
270+
opacity: 0; cursor: pointer;
271+
}
272+
.cockpit-tab-trigger:hover .cockpit-tab-trigger__close,
273+
.cockpit-tab-trigger[data-state="active"] .cockpit-tab-trigger__close { opacity: 1; }
274+
.cockpit-tab-trigger__close:hover { background: var(--ds-accent-surface); color: var(--ds-text-primary); }

apps/cockpit/src/components/api-mode/api-mode.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ export function ApiMode({ docSections, hasCodeFiles = false }: ApiModeProps) {
130130
const pySections = docSections.filter((s) => s.language === 'python');
131131

132132
return (
133-
<section aria-label="API mode" className="h-full overflow-auto py-4 px-4 md:px-8">
134-
<div className="cockpit-prose" style={{ maxWidth: '48rem' }}>
133+
<section aria-label="API mode" className="h-full overflow-auto py-6 px-4 md:px-8">
134+
<div className="cockpit-prose cockpit-prose--wide">
135135
{tsSections.length > 0 ? (
136136
<div>
137137
<h3

apps/cockpit/src/components/code-mode/code-mode.spec.tsx

Lines changed: 168 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ describe('CodeMode', () => {
5252
expect(container.textContent).toContain('export default function Page() {}');
5353

5454
const tabs = Array.from(container.querySelectorAll('[role="tab"]'));
55-
expect(tabs.map((tab) => tab.textContent)).toEqual(['page.tsx', 'index.ts']);
55+
expect(tabs.map((tab) => (tab.textContent ?? '').replace(/×/g, '').trim())).toEqual(['page.tsx', 'index.ts']);
5656

5757
act(() => {
5858
(tabs[1] as HTMLElement).dispatchEvent(
@@ -105,12 +105,12 @@ describe('CodeMode', () => {
105105
});
106106

107107
const tabs = Array.from(container.querySelectorAll('[role="tab"]'));
108-
const tabLabels = tabs.map((tab) => tab.textContent);
108+
const tabLabels = tabs.map((tab) => (tab.textContent ?? '').replace(/×/g, '').trim());
109109
expect(tabLabels).toContain('app.tsx');
110110
expect(tabLabels).toContain('system.md');
111111

112112
act(() => {
113-
const promptTab = tabs.find((tab) => tab.textContent === 'system.md') as HTMLElement;
113+
const promptTab = tabs.find((tab) => (tab.textContent ?? '').replace(/×/g, '').trim() === 'system.md') as HTMLElement;
114114
promptTab.dispatchEvent(
115115
new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })
116116
);
@@ -119,6 +119,171 @@ describe('CodeMode', () => {
119119
expect(container.textContent).toContain('You are a helpful assistant.');
120120
});
121121

122+
it('pre-opens all code, backend, and prompt files as tabs with the first code file active', () => {
123+
container = document.createElement('div');
124+
document.body.appendChild(container);
125+
root = createRoot(container);
126+
127+
act(() => {
128+
root!.render(
129+
<CodeMode
130+
entryTitle="Planning"
131+
codeAssetPaths={['src/a.ts']}
132+
backendAssetPaths={['backend/graph.py']}
133+
codeFiles={{
134+
'src/a.ts': '<pre class="shiki"><code>a</code></pre>',
135+
'backend/graph.py': '<pre class="shiki"><code>g</code></pre>',
136+
}}
137+
promptFiles={{ 'prompts/p.md': 'hello' }}
138+
/>,
139+
);
140+
});
141+
142+
const tabLabels = Array.from(container.querySelectorAll('[role="tab"]')).map((t) => (t.textContent ?? '').replace(/×/g, '').trim());
143+
expect(tabLabels).toEqual(['a.ts', 'graph.py', 'p.md']);
144+
145+
const active = container.querySelector('[role="tab"][data-state="active"]');
146+
expect((active?.textContent ?? '').replace(/×/g, '').trim()).toBe('a.ts');
147+
});
148+
149+
it('opens a closed file and activates it when the tree row is clicked', () => {
150+
container = document.createElement('div');
151+
document.body.appendChild(container);
152+
root = createRoot(container);
153+
154+
act(() => {
155+
root!.render(
156+
<CodeMode
157+
entryTitle="Planning"
158+
codeAssetPaths={['src/a.ts', 'src/b.ts']}
159+
backendAssetPaths={[]}
160+
codeFiles={{
161+
'src/a.ts': '<pre class="shiki"><code>a</code></pre>',
162+
'src/b.ts': '<pre class="shiki"><code>b</code></pre>',
163+
}}
164+
promptFiles={{}}
165+
/>,
166+
);
167+
});
168+
169+
// Locate the tree row for b.ts and click it. Since FT5 has both files pre-opened,
170+
// this verifies the tree-click path even before FT6 introduces close behaviour.
171+
const bRow = Array.from(container.querySelectorAll('[data-file-row]')).find(
172+
(el) => el.querySelector('[data-file-label]')?.textContent === 'b.ts',
173+
) as HTMLElement;
174+
expect(bRow).toBeDefined();
175+
176+
act(() => { bRow.click(); });
177+
178+
const active = container.querySelector('[role="tab"][data-state="active"]');
179+
expect((active?.textContent ?? '').replace(/×/g, '').trim()).toBe('b.ts');
180+
});
181+
182+
it('closes a tab and activates its left neighbor', () => {
183+
container = document.createElement('div');
184+
document.body.appendChild(container);
185+
root = createRoot(container);
186+
187+
act(() => {
188+
root!.render(
189+
<CodeMode
190+
entryTitle="Planning"
191+
codeAssetPaths={['src/a.ts', 'src/b.ts', 'src/c.ts']}
192+
backendAssetPaths={[]}
193+
codeFiles={{
194+
'src/a.ts': '<pre class="shiki"><code>a</code></pre>',
195+
'src/b.ts': '<pre class="shiki"><code>b</code></pre>',
196+
'src/c.ts': '<pre class="shiki"><code>c</code></pre>',
197+
}}
198+
promptFiles={{}}
199+
/>,
200+
);
201+
});
202+
203+
// Activate b.ts, then close it.
204+
const bTab = Array.from(container.querySelectorAll('[role="tab"]')).find(
205+
(el) => el.textContent?.startsWith('b.ts'),
206+
) as HTMLElement;
207+
act(() => {
208+
bTab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
209+
});
210+
211+
const closeBtn = container.querySelector('[role="tab"][data-state="active"] [data-tab-close]') as HTMLElement;
212+
expect(closeBtn).not.toBeNull();
213+
act(() => { closeBtn.click(); });
214+
215+
const tabs = Array.from(container.querySelectorAll('[role="tab"]')).map((t) =>
216+
(t.textContent ?? '').replace(/×/g, '').trim(),
217+
);
218+
expect(tabs).toEqual(['a.ts', 'c.ts']);
219+
220+
const active = container.querySelector('[role="tab"][data-state="active"]');
221+
expect((active?.textContent ?? '').startsWith('a.ts')).toBe(true);
222+
});
223+
224+
it('shows the empty state after the last tab is closed', () => {
225+
container = document.createElement('div');
226+
document.body.appendChild(container);
227+
root = createRoot(container);
228+
229+
act(() => {
230+
root!.render(
231+
<CodeMode
232+
entryTitle="Planning"
233+
codeAssetPaths={['src/only.ts']}
234+
backendAssetPaths={[]}
235+
codeFiles={{ 'src/only.ts': '<pre class="shiki"><code>x</code></pre>' }}
236+
promptFiles={{}}
237+
/>,
238+
);
239+
});
240+
241+
const closeBtn = container.querySelector('[role="tab"] [data-tab-close]') as HTMLElement;
242+
act(() => { closeBtn.click(); });
243+
244+
expect(container.querySelectorAll('[role="tab"]')).toHaveLength(0);
245+
expect(container.textContent).toContain('Select a file from the tree');
246+
});
247+
248+
it('closes a tab when Enter is pressed on the close button', () => {
249+
container = document.createElement('div');
250+
document.body.appendChild(container);
251+
root = createRoot(container);
252+
253+
act(() => {
254+
root!.render(
255+
<CodeMode
256+
entryTitle="Planning"
257+
codeAssetPaths={['src/a.ts', 'src/b.ts']}
258+
backendAssetPaths={[]}
259+
codeFiles={{
260+
'src/a.ts': '<pre class="shiki"><code>a</code></pre>',
261+
'src/b.ts': '<pre class="shiki"><code>b</code></pre>',
262+
}}
263+
promptFiles={{}}
264+
/>,
265+
);
266+
});
267+
268+
// The first tab (a.ts) is active by default; its close span is focusable.
269+
const closeBtn = container.querySelector(
270+
'[role="tab"][data-state="active"] [data-tab-close]',
271+
) as HTMLElement;
272+
expect(closeBtn).not.toBeNull();
273+
expect(closeBtn.getAttribute('tabindex')).toBe('0');
274+
275+
act(() => {
276+
closeBtn.dispatchEvent(
277+
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
278+
);
279+
});
280+
281+
const tabs = Array.from(container.querySelectorAll('[role="tab"]')).map((t) =>
282+
(t.textContent ?? '').replace(/×/g, '').trim(),
283+
);
284+
expect(tabs).toEqual(['b.ts']);
285+
});
286+
122287
it('fires cockpit:code_copied when the Copy button is clicked', () => {
123288
container = document.createElement('div');
124289
document.body.appendChild(container);

0 commit comments

Comments
 (0)