diff --git a/apps/cockpit/src/app/cockpit.css b/apps/cockpit/src/app/cockpit.css index 6ec4123c..88e5c95b 100644 --- a/apps/cockpit/src/app/cockpit.css +++ b/apps/cockpit/src/app/cockpit.css @@ -205,13 +205,16 @@ pre.shiki { font-size: 0.75rem; } -/* Shared prose layer — docs + api */ +/* Shared prose layer — docs + api + code mode content */ .cockpit-prose { max-width: 42rem; + margin-inline: auto; font-size: 0.9rem; line-height: 1.7; color: var(--ds-text-secondary); } +.cockpit-prose--wide { max-width: 48rem; } +.cockpit-prose--code { max-width: 56rem; } .cockpit-prose h1, .cockpit-prose h2, .cockpit-prose h3 { font-family: var(--font-garamond), var(--ds-font-serif); color: var(--ds-text-primary); @@ -231,3 +234,41 @@ pre.shiki { .cockpit-prose table.params { border-collapse: collapse; margin: 0.5rem 0; } .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); } .cockpit-prose table.params td { padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid var(--ds-border); } + +/* Code-mode file tree */ +.cockpit-file-tree { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.7; } +.cockpit-file-tree ul { list-style: none; padding: 0; margin: 0; } +.cockpit-file-tree__file, +.cockpit-file-tree__folder { + display: flex; align-items: center; gap: 0.4rem; flex: 1; min-width: 0; + padding: 3px 0.75rem 3px 0.75rem; background: transparent; border: 0; text-align: left; cursor: pointer; + color: var(--ds-text-secondary); font-family: var(--font-mono), "JetBrains Mono", monospace; font-size: 12px; + border-left: 2px solid transparent; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.cockpit-file-tree__folder { color: var(--ds-text-muted); display: flex; align-items: center; } +.cockpit-file-tree__caret { font-size: 9px; color: var(--ds-text-muted); width: 0.65rem; flex-shrink: 0; } +.cockpit-file-tree__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cockpit-file-tree__chip { + font-family: var(--font-mono), monospace; font-size: 9px; + padding: 1px 5px; border-radius: 3px; margin-right: 0.5rem; flex-shrink: 0; + background: var(--ds-accent-surface); color: var(--ds-accent); + opacity: 0.85; +} +.cockpit-file-tree__file:hover { color: var(--ds-text-primary); } +.cockpit-file-tree__file[aria-current="true"] { + background: var(--ds-accent-surface); + color: var(--ds-text-primary); + border-left-color: var(--ds-accent); +} + +/* Tab close (×) on Code-mode tabs */ +.cockpit-tab-trigger { display: inline-flex; align-items: center; gap: 0.4rem; } +.cockpit-tab-trigger__close { + display: inline-flex; align-items: center; justify-content: center; + width: 0.95rem; height: 0.95rem; border-radius: 0.2rem; + color: var(--ds-text-muted); font-size: 0.85rem; line-height: 1; + opacity: 0; cursor: pointer; +} +.cockpit-tab-trigger:hover .cockpit-tab-trigger__close, +.cockpit-tab-trigger[data-state="active"] .cockpit-tab-trigger__close { opacity: 1; } +.cockpit-tab-trigger__close:hover { background: var(--ds-accent-surface); color: var(--ds-text-primary); } diff --git a/apps/cockpit/src/components/api-mode/api-mode.tsx b/apps/cockpit/src/components/api-mode/api-mode.tsx index 8fabd095..47a56d0d 100644 --- a/apps/cockpit/src/components/api-mode/api-mode.tsx +++ b/apps/cockpit/src/components/api-mode/api-mode.tsx @@ -130,8 +130,8 @@ export function ApiMode({ docSections, hasCodeFiles = false }: ApiModeProps) { const pySections = docSections.filter((s) => s.language === 'python'); return ( -
-
+
+
{tsSections.length > 0 ? (

{ expect(container.textContent).toContain('export default function Page() {}'); const tabs = Array.from(container.querySelectorAll('[role="tab"]')); - expect(tabs.map((tab) => tab.textContent)).toEqual(['page.tsx', 'index.ts']); + expect(tabs.map((tab) => (tab.textContent ?? '').replace(/×/g, '').trim())).toEqual(['page.tsx', 'index.ts']); act(() => { (tabs[1] as HTMLElement).dispatchEvent( @@ -105,12 +105,12 @@ describe('CodeMode', () => { }); const tabs = Array.from(container.querySelectorAll('[role="tab"]')); - const tabLabels = tabs.map((tab) => tab.textContent); + const tabLabels = tabs.map((tab) => (tab.textContent ?? '').replace(/×/g, '').trim()); expect(tabLabels).toContain('app.tsx'); expect(tabLabels).toContain('system.md'); act(() => { - const promptTab = tabs.find((tab) => tab.textContent === 'system.md') as HTMLElement; + const promptTab = tabs.find((tab) => (tab.textContent ?? '').replace(/×/g, '').trim() === 'system.md') as HTMLElement; promptTab.dispatchEvent( new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }) ); @@ -119,6 +119,171 @@ describe('CodeMode', () => { expect(container.textContent).toContain('You are a helpful assistant.'); }); + it('pre-opens all code, backend, and prompt files as tabs with the first code file active', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root!.render( + a', + 'backend/graph.py': '
g
', + }} + promptFiles={{ 'prompts/p.md': 'hello' }} + />, + ); + }); + + const tabLabels = Array.from(container.querySelectorAll('[role="tab"]')).map((t) => (t.textContent ?? '').replace(/×/g, '').trim()); + expect(tabLabels).toEqual(['a.ts', 'graph.py', 'p.md']); + + const active = container.querySelector('[role="tab"][data-state="active"]'); + expect((active?.textContent ?? '').replace(/×/g, '').trim()).toBe('a.ts'); + }); + + it('opens a closed file and activates it when the tree row is clicked', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root!.render( + a', + 'src/b.ts': '
b
', + }} + promptFiles={{}} + />, + ); + }); + + // Locate the tree row for b.ts and click it. Since FT5 has both files pre-opened, + // this verifies the tree-click path even before FT6 introduces close behaviour. + const bRow = Array.from(container.querySelectorAll('[data-file-row]')).find( + (el) => el.querySelector('[data-file-label]')?.textContent === 'b.ts', + ) as HTMLElement; + expect(bRow).toBeDefined(); + + act(() => { bRow.click(); }); + + const active = container.querySelector('[role="tab"][data-state="active"]'); + expect((active?.textContent ?? '').replace(/×/g, '').trim()).toBe('b.ts'); + }); + + it('closes a tab and activates its left neighbor', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root!.render( + a', + 'src/b.ts': '
b
', + 'src/c.ts': '
c
', + }} + promptFiles={{}} + />, + ); + }); + + // Activate b.ts, then close it. + const bTab = Array.from(container.querySelectorAll('[role="tab"]')).find( + (el) => el.textContent?.startsWith('b.ts'), + ) as HTMLElement; + act(() => { + bTab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })); + }); + + const closeBtn = container.querySelector('[role="tab"][data-state="active"] [data-tab-close]') as HTMLElement; + expect(closeBtn).not.toBeNull(); + act(() => { closeBtn.click(); }); + + const tabs = Array.from(container.querySelectorAll('[role="tab"]')).map((t) => + (t.textContent ?? '').replace(/×/g, '').trim(), + ); + expect(tabs).toEqual(['a.ts', 'c.ts']); + + const active = container.querySelector('[role="tab"][data-state="active"]'); + expect((active?.textContent ?? '').startsWith('a.ts')).toBe(true); + }); + + it('shows the empty state after the last tab is closed', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root!.render( + x' }} + promptFiles={{}} + />, + ); + }); + + const closeBtn = container.querySelector('[role="tab"] [data-tab-close]') as HTMLElement; + act(() => { closeBtn.click(); }); + + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(0); + expect(container.textContent).toContain('Select a file from the tree'); + }); + + it('closes a tab when Enter is pressed on the close button', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root!.render( + a', + 'src/b.ts': '
b
', + }} + promptFiles={{}} + />, + ); + }); + + // The first tab (a.ts) is active by default; its close span is focusable. + const closeBtn = container.querySelector( + '[role="tab"][data-state="active"] [data-tab-close]', + ) as HTMLElement; + expect(closeBtn).not.toBeNull(); + expect(closeBtn.getAttribute('tabindex')).toBe('0'); + + act(() => { + closeBtn.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + }); + + const tabs = Array.from(container.querySelectorAll('[role="tab"]')).map((t) => + (t.textContent ?? '').replace(/×/g, '').trim(), + ); + expect(tabs).toEqual(['b.ts']); + }); + it('fires cockpit:code_copied when the Copy button is clicked', () => { container = document.createElement('div'); document.body.appendChild(container); diff --git a/apps/cockpit/src/components/code-mode/code-mode.tsx b/apps/cockpit/src/components/code-mode/code-mode.tsx index ae52163b..5e013fc6 100644 --- a/apps/cockpit/src/components/code-mode/code-mode.tsx +++ b/apps/cockpit/src/components/code-mode/code-mode.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { track } from '../../lib/analytics/client'; +import { FileTree } from './file-tree'; interface CodeModeProps { entryTitle: string; @@ -53,8 +54,73 @@ function CodeFileContent({ } export function CodeMode({ entryTitle, codeAssetPaths, backendAssetPaths, codeFiles, promptFiles, capability }: CodeModeProps) { - const promptPaths = Object.keys(promptFiles); - const allPaths = [...codeAssetPaths, ...backendAssetPaths, ...promptPaths]; + const promptPaths = React.useMemo(() => Object.keys(promptFiles), [promptFiles]); + const allPaths = React.useMemo( + () => [...codeAssetPaths, ...backendAssetPaths, ...promptPaths], + [codeAssetPaths, backendAssetPaths, promptPaths], + ); + + const [openPaths, setOpenPaths] = React.useState(allPaths); + const [activePath, setActivePath] = React.useState(allPaths[0] ?? null); + + const [treeCollapsed, setTreeCollapsed] = React.useState(false); + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + try { + if (typeof window !== 'undefined' && window.localStorage.getItem('cockpit:codeTree:collapsed') === '1') { + setTreeCollapsed(true); + } + } catch { + /* ignore */ + } + // Mark mounted on next animation frame so the localStorage state lands BEFORE + // the transition class is applied — no animated collapse on hard reload. + const id = requestAnimationFrame(() => setMounted(true)); + return () => cancelAnimationFrame(id); + }, []); + + const toggleTreeCollapsed = React.useCallback(() => { + setTreeCollapsed((prev) => { + const next = !prev; + try { + if (typeof window !== 'undefined') { + window.localStorage.setItem('cockpit:codeTree:collapsed', next ? '1' : '0'); + } + } catch { + /* ignore */ + } + return next; + }); + }, []); + + // If the capability changes (allPaths changes identity), reset open + active. + React.useEffect(() => { + setOpenPaths(allPaths); + setActivePath(allPaths[0] ?? null); + }, [allPaths]); + + const handleSelect = React.useCallback((path: string) => { + setOpenPaths((prev) => (prev.includes(path) ? prev : [...prev, path])); + setActivePath(path); + }, []); + + const handleClose = React.useCallback((path: string) => { + setOpenPaths((prev) => { + const idx = prev.indexOf(path); + if (idx < 0) return prev; + const next = prev.filter((p) => p !== path); + setActivePath((current) => { + if (current !== path) return current; + if (next.length === 0) return null; + // Activate the left neighbor; if the closed tab was leftmost (idx 0), + // the new leftmost (next[0]) becomes active. + const neighborIdx = Math.max(0, idx - 1); + return next[neighborIdx]; + }); + return next; + }); + }, []); if (allPaths.length === 0) { return ( @@ -64,48 +130,106 @@ export function CodeMode({ entryTitle, codeAssetPaths, backendAssetPaths, codeFi ); } - const defaultPath = codeAssetPaths[0] ?? backendAssetPaths[0] ?? promptPaths[0]; + const isPromptPath = (path: string) => promptPaths.includes(path); return ( -
- - - {codeAssetPaths.map((path) => ( - - {getTabLabel(path)} - - ))} - {backendAssetPaths.map((path) => ( - - {getTabLabel(path)} - - ))} - {promptPaths.map((path) => ( - - {getTabLabel(path)} - - ))} - - - {[...codeAssetPaths, ...backendAssetPaths].map((path) => ( - - - - ))} - - {promptPaths.map((path) => { - const content = promptFiles[path]; - return ( - - {content ? ( -
{content}
- ) : ( -

No content for {getTabLabel(path)}

- )} -
- ); - })} -
+
+ + +
+ {treeCollapsed ? ( + + ) : null} + {openPaths.length === 0 || activePath === null ? ( +
+

Select a file from the tree to begin.

+
+ ) : ( + setActivePath(v)} + className="flex flex-col h-full" + > + + {openPaths.map((path) => { + const label = getTabLabel(path); + return ( + + {label} + { e.stopPropagation(); }} + onClick={(e) => { e.stopPropagation(); handleClose(path); }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + handleClose(path); + } + }} + className="cockpit-tab-trigger__close" + >× + + ); + })} + + + {openPaths.filter((p) => !isPromptPath(p)).map((path) => ( + +
+ +
+
+ ))} + + {openPaths.filter(isPromptPath).map((path) => { + const content = promptFiles[path]; + return ( + +
+ {content ? ( +
{content}
+ ) : ( +

No content for {getTabLabel(path)}

+ )} +
+
+ ); + })} +
+ )} +
); } diff --git a/apps/cockpit/src/components/code-mode/file-tree.spec.tsx b/apps/cockpit/src/components/code-mode/file-tree.spec.tsx new file mode 100644 index 00000000..10fd3610 --- /dev/null +++ b/apps/cockpit/src/components/code-mode/file-tree.spec.tsx @@ -0,0 +1,88 @@ +/** @vitest-environment jsdom */ +import React from 'react'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FileTree } from './file-tree'; + +describe('FileTree', () => { + let container: HTMLDivElement | undefined; + let root: ReturnType | undefined; + + afterEach(() => { + act(() => { root?.unmount(); }); + container?.remove(); + vi.clearAllMocks(); + }); + + function render(node: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { root!.render(node); }); + } + + it('renders a file row for every path', () => { + const onSelect = vi.fn(); + render( + + ); + + const labels = Array.from(container!.querySelectorAll('[data-file-label]')).map((el) => el.textContent); + expect(labels).toContain('planning.component.ts'); + expect(labels).toContain('graph.py'); + expect(labels).toContain('planning.md'); + }); + + it('marks the active file row with aria-current="true"', () => { + render( + {}} + /> + ); + + const active = container!.querySelector('[data-file-row][aria-current="true"]'); + expect(active?.querySelector('[data-file-label]')?.textContent).toBe('b.py'); + }); + + it('emits onSelect with the file path when a file row is clicked', () => { + const onSelect = vi.fn(); + render( + + ); + + const row = container!.querySelector('[data-file-row]') as HTMLElement; + act(() => { row.click(); }); + + expect(onSelect).toHaveBeenCalledWith('angular/src/app/planning.component.ts'); + }); + + it('collapses a folder when its header is clicked and hides its children', () => { + render( + {}} + /> + ); + + // Folder "angular/src/app" is the only top-level row (compact-merged). + const folder = container!.querySelector('[data-folder-row]') as HTMLElement; + expect(folder.textContent).toContain('angular/src/app'); + expect(container!.querySelectorAll('[data-file-row]')).toHaveLength(2); + + act(() => { folder.click(); }); + + expect(container!.querySelectorAll('[data-file-row]')).toHaveLength(0); + }); +}); diff --git a/apps/cockpit/src/components/code-mode/file-tree.tsx b/apps/cockpit/src/components/code-mode/file-tree.tsx new file mode 100644 index 00000000..1b17fe2c --- /dev/null +++ b/apps/cockpit/src/components/code-mode/file-tree.tsx @@ -0,0 +1,113 @@ +'use client'; + +import React from 'react'; +import { buildTree, type FolderNode, type TreeNode } from './file-tree.utils'; + +interface FileTreeProps { + paths: readonly string[]; + activePath: string | null; + onSelect: (path: string) => void; +} + +function langChip(label: string): string | null { + const dot = label.lastIndexOf('.'); + if (dot <= 0) return null; + return label.slice(dot + 1).toUpperCase(); +} + +export function FileTree({ paths, activePath, onSelect }: FileTreeProps) { + const tree = React.useMemo(() => buildTree(paths), [paths]); + const [collapsedFolders, setCollapsedFolders] = React.useState>(() => new Set()); + + const toggleFolder = React.useCallback((id: string) => { + setCollapsedFolders((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + return ( +
    + {tree.map((node, i) => ( + + ))} +
+ ); +} + +interface NodeProps { + node: TreeNode; + depth: number; + folderId: string; + activePath: string | null; + collapsedFolders: ReadonlySet; + onToggleFolder: (id: string) => void; + onSelect: (path: string) => void; +} + +function Node({ node, depth, folderId, activePath, collapsedFolders, onToggleFolder, onSelect }: NodeProps) { + if (node.kind === 'file') { + const chip = langChip(node.label); + const isActive = activePath === node.path; + return ( +
  • + +
  • + ); + } + + const folder = node as FolderNode; + const isCollapsed = collapsedFolders.has(folderId); + return ( +
  • + + {!isCollapsed ? ( +
      + {folder.children.map((child, i) => ( + + ))} +
    + ) : null} +
  • + ); +} diff --git a/apps/cockpit/src/components/code-mode/file-tree.utils.spec.ts b/apps/cockpit/src/components/code-mode/file-tree.utils.spec.ts new file mode 100644 index 00000000..f7262dac --- /dev/null +++ b/apps/cockpit/src/components/code-mode/file-tree.utils.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { buildTree, type TreeNode } from './file-tree.utils'; + +describe('buildTree', () => { + it('returns an empty array when no paths are given', () => { + expect(buildTree([])).toEqual([]); + }); + + it('returns a flat list of file nodes when there are no folders after trimming', () => { + const tree = buildTree(['planning.md']); + expect(tree).toEqual([ + { kind: 'file', path: 'planning.md', label: 'planning.md' }, + ]); + }); + + it('strips the common directory prefix shared by all paths', () => { + const tree = buildTree([ + 'cockpit/planning/angular/app.config.ts', + 'cockpit/planning/python/graph.py', + ]); + // common prefix "cockpit/planning/" is removed; "angular" and "python" become top-level folders + expect(tree.map((n) => n.kind)).toEqual(['folder', 'folder']); + expect(tree.map((n) => n.label)).toEqual(['angular', 'python']); + }); + + it('compacts single-child folder chains into one row', () => { + const tree = buildTree([ + 'angular/src/app/planning.component.ts', + 'angular/src/app/app.config.ts', + ]); + // angular and src each have a single sub-folder; app has two files, + // so the merge produces one "angular/src/app" folder row. + expect(tree).toHaveLength(1); + expect(tree[0]).toMatchObject({ kind: 'folder', label: 'angular/src/app' }); + expect((tree[0] as { children: TreeNode[] }).children.map((c) => c.label).sort()).toEqual([ + 'app.config.ts', + 'planning.component.ts', + ]); + }); + + it('keeps a folder distinct from its children when it has both a file and a subfolder', () => { + const tree = buildTree([ + 'angular/src/app/planning.component.ts', + 'angular/src/app/views/plan-checklist.component.ts', + ]); + // angular/src/app contains a file AND a "views" subfolder → does not merge with "views" + const top = tree[0] as { kind: 'folder'; label: string; children: TreeNode[] }; + expect(top.label).toBe('angular/src/app'); + expect(top.children).toHaveLength(2); + const labels = top.children.map((c) => c.label).sort(); + expect(labels).toEqual(['planning.component.ts', 'views']); + }); + + it('renders all files flat when no common prefix and only one segment each', () => { + const tree = buildTree(['a.ts', 'b.py']); + expect(tree).toEqual([ + { kind: 'file', path: 'a.ts', label: 'a.ts' }, + { kind: 'file', path: 'b.py', label: 'b.py' }, + ]); + }); + + it('normalizes leading and trailing slashes in paths', () => { + const tree = buildTree(['/angular/src/app/foo.ts', 'angular/src/app/bar.ts']); + expect(tree).toHaveLength(1); + const folder = tree[0] as { kind: 'folder'; label: string; children: TreeNode[] }; + expect(folder.kind).toBe('folder'); + expect(folder.label).toBe('angular/src/app'); + expect(folder.children.map((c) => c.label).sort()).toEqual(['bar.ts', 'foo.ts']); + // FileNode.path must not retain the stripped leading slash + const foo = folder.children.find((c) => c.label === 'foo.ts') as { path: string }; + expect(foo.path).toBe('angular/src/app/foo.ts'); + }); +}); diff --git a/apps/cockpit/src/components/code-mode/file-tree.utils.ts b/apps/cockpit/src/components/code-mode/file-tree.utils.ts new file mode 100644 index 00000000..e6c28600 --- /dev/null +++ b/apps/cockpit/src/components/code-mode/file-tree.utils.ts @@ -0,0 +1,60 @@ +export type FileNode = { kind: 'file'; path: string; label: string }; +export type FolderNode = { kind: 'folder'; label: string; children: TreeNode[] }; +export type TreeNode = FileNode | FolderNode; + + +function insert(root: FolderNode, segments: string[], fullPath: string): void { + let node = root; + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]; + let child = node.children.find((c): c is FolderNode => c.kind === 'folder' && c.label === seg); + if (!child) { + child = { kind: 'folder', label: seg, children: [] }; + node.children.push(child); + } + node = child; + } + const filename = segments[segments.length - 1]; + node.children.push({ kind: 'file', path: fullPath, label: filename }); +} + +function compact(nodes: TreeNode[]): TreeNode[] { + return nodes.map((node) => { + if (node.kind === 'file') return node; + let folder = node; + // Merge while this folder has exactly one child AND that child is a folder. + while (folder.children.length === 1 && folder.children[0].kind === 'folder') { + const only = folder.children[0]; + folder = { kind: 'folder', label: `${folder.label}/${only.label}`, children: only.children }; + } + return { ...folder, children: compact(folder.children) }; + }); +} + +/** Peel off a single top-level compacted folder when it is a pure "namespace" + * node — i.e. it has exactly one top-level item, that item is a folder, and + * every one of its direct children is also a folder (no files at that level). + * This implements the common-directory-prefix trimming: a shared root that + * only contains sub-folders is invisible; one that contains files is shown. */ +function peelPrefix(nodes: TreeNode[]): TreeNode[] { + if ( + nodes.length === 1 && + nodes[0].kind === 'folder' && + nodes[0].children.length > 0 && + nodes[0].children.every((c) => c.kind === 'folder') + ) { + return peelPrefix(nodes[0].children); + } + return nodes; +} + +export function buildTree(paths: readonly string[]): TreeNode[] { + const normalized = paths + .map((p) => p.replace(/^\/+/, '').replace(/\/+$/, '')) + .filter((p) => p.length > 0); + if (normalized.length === 0) return []; + + const root: FolderNode = { kind: 'folder', label: '', children: [] }; + normalized.forEach((p) => insert(root, p.split('/'), p)); + return peelPrefix(compact(root.children)); +} diff --git a/docs/superpowers/plans/2026-06-03-code-mode-file-tree.md b/docs/superpowers/plans/2026-06-03-code-mode-file-tree.md new file mode 100644 index 00000000..addcbdef --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-code-mode-file-tree.md @@ -0,0 +1,1169 @@ +# Code Mode File Tree — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a VS Code / Zed–style file tree on the left of the Code-mode tab strip at `lg:` widths, with all files pre-opened as tabs, tree-click activates/opens, tab close (×), and a persisted collapse toggle. + +**Architecture:** New pure-presentational `FileTree` component fed by a pure `buildTree(paths)` helper (TDD-friendly, no DOM). `CodeMode` migrates from Radix `Tabs.defaultValue` to explicit `openPaths` + `activePath` state and adds the responsive `lg:grid` layout with a collapse toggle persisted via `localStorage`. Zero new dependencies; all colors via existing `--ds-*` tokens; below `lg:` the layout falls back to today's tabs-only Code mode. + +**Tech Stack:** React, Tailwind v4 (CSS-based, no config file), Radix `Tabs`, Vitest + jsdom (tests use `createRoot`/`act` for interactive components, `renderToStaticMarkup` for pure-presentational ones — see the repo's existing specs). + +**Spec:** `docs/superpowers/specs/2026-06-03-code-mode-file-tree-design.md` + +**Conventions:** +- Run a single test file with `npx nx test cockpit -- `; full suite with `npx nx test cockpit`. +- TS path alias `@/components/...` resolves to `apps/cockpit/src/components/...`. +- Commit after each task. + +--- + +## File map + +| File | Change | Responsibility | +|------|--------|----------------| +| `apps/cockpit/src/app/cockpit.css` | Modify | Add `.cockpit-prose--wide` / `.cockpit-prose--code` width modifiers + `margin-inline: auto`; add file-tree styles in Task 4. | +| `apps/cockpit/src/components/api-mode/api-mode.tsx` | Modify (Task 1) | Use `.cockpit-prose--wide` class instead of inline `maxWidth`; outer `py-4` → `py-6`. | +| `apps/cockpit/src/components/code-mode/code-mode.tsx` | Modify | Task 1: add `.cockpit-prose--code` wrapper + outer padding. Tasks 3, 5, 6: state migration, FileTree integration, close + collapse. | +| `apps/cockpit/src/components/code-mode/code-mode.spec.tsx` | Modify (Tasks 3, 5, 6) | Add assertions for new state semantics + close + last-tab-close empty state. | +| `apps/cockpit/src/components/code-mode/file-tree.utils.ts` | **NEW** (Task 2) | Pure `buildTree(paths)` returning a discriminated `FileNode | FolderNode` tree; common-prefix trimming + compact folder chains. | +| `apps/cockpit/src/components/code-mode/file-tree.utils.spec.ts` | **NEW** (Task 2) | TDD spec for `buildTree`. | +| `apps/cockpit/src/components/code-mode/file-tree.tsx` | **NEW** (Task 4) | Pure presentational tree: props `{ paths, activePath, onSelect }`. Owns folder-collapse state internally. | +| `apps/cockpit/src/components/code-mode/file-tree.spec.tsx` | **NEW** (Task 4) | TDD spec for `FileTree`: rendering, click → onSelect, folder header toggles. | + +--- + +## Task 1: Land the unified prose-width + Code-mode padding wrapper + +This brings already-staged-in-working-tree CSS/component improvements onto the branch as a clean commit. They are referenced by the spec ("the `.cockpit-prose--code` 56rem wrapper") and need to land before the file tree work. Also reverts a local-only `next.config.ts` hack and the working-tree `node_modules` symlink from the prior dev-server session. + +**Files:** +- Modify: `apps/cockpit/src/app/cockpit.css` +- Modify: `apps/cockpit/src/components/api-mode/api-mode.tsx` +- Modify: `apps/cockpit/src/components/code-mode/code-mode.tsx` +- Revert (do NOT commit): `apps/cockpit/next.config.ts` (local-only Turbopack root hack), `node_modules` symlink + +- [ ] **Step 1: Revert local-only changes** + +```bash +git checkout -- apps/cockpit/next.config.ts +rm -f node_modules +``` + +If `node_modules` was a real directory (not a symlink) and `rm -f` doesn't work, leave it: it's already in `.gitignore`. + +- [ ] **Step 2: Confirm the three legitimate changes are in place** + +Run: `git diff --stat apps/cockpit/src/app/cockpit.css apps/cockpit/src/components/api-mode/api-mode.tsx apps/cockpit/src/components/code-mode/code-mode.tsx` +Expected: all three files show modifications. If `cockpit.css` does not contain `.cockpit-prose--wide`, apply the edit in Step 3; same for the other two if missing. + +- [ ] **Step 3: Apply (or verify) the cockpit.css additions** + +In `apps/cockpit/src/app/cockpit.css`, the `.cockpit-prose` block should read: + +```css +/* Shared prose layer — docs + api + code mode content */ +.cockpit-prose { + max-width: 42rem; + margin-inline: auto; + font-size: 0.9rem; + line-height: 1.7; + color: var(--ds-text-secondary); +} +.cockpit-prose--wide { max-width: 48rem; } +.cockpit-prose--code { max-width: 56rem; } +``` + +(The original block had no `margin-inline` and no modifier classes; add both.) + +- [ ] **Step 4: Apply (or verify) the api-mode change** + +In `apps/cockpit/src/components/api-mode/api-mode.tsx`, the outer `
    ` of the non-empty case should read: + +```tsx +
    +
    +``` + +(Changes from `py-4` to `py-6`; replaces `style={{ maxWidth: '48rem' }}` with the modifier class.) + +- [ ] **Step 5: Apply (or verify) the code-mode change** + +In `apps/cockpit/src/components/code-mode/code-mode.tsx`, both `TabsContent` panels (code/backend files and prompt files) should wrap their children in `.cockpit-prose.cockpit-prose--code` and have the outer padding `py-6 px-4 md:px-8`. The code-asset panel: + +```tsx +{[...codeAssetPaths, ...backendAssetPaths].map((path) => ( + +
    + +
    +
    +))} +``` + +And the prompt panel mirrors it (same outer className, same wrapper, prompt `
    ` or fallback `

    ` inside). + +- [ ] **Step 6: Run tests** + +Run: `npx nx test cockpit` +Expected: PASS. (Class additions are purely additive; no spec asserts on the inline `maxWidth` or the outer `py-4`.) + +- [ ] **Step 7: Commit** + +```bash +git add apps/cockpit/src/app/cockpit.css apps/cockpit/src/components/api-mode/api-mode.tsx apps/cockpit/src/components/code-mode/code-mode.tsx +git commit -m "refactor(cockpit): unified prose width modifiers + code-mode padding wrapper" +``` + +--- + +## Task 2: `buildTree` utility (TDD) + +A pure function that turns a flat list of file paths into a tree with common-prefix trimming and **compact folder chains** (single-child folder chains merged into one row, matching the spec's example). This is the only logic that needs unit tests in isolation; the rest of the tree work is presentation. + +**Files:** +- Create: `apps/cockpit/src/components/code-mode/file-tree.utils.ts` +- Create: `apps/cockpit/src/components/code-mode/file-tree.utils.spec.ts` + +- [ ] **Step 1: Write the failing test** + +`apps/cockpit/src/components/code-mode/file-tree.utils.spec.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { buildTree, type TreeNode } from './file-tree.utils'; + +describe('buildTree', () => { + it('returns an empty array when no paths are given', () => { + expect(buildTree([])).toEqual([]); + }); + + it('returns a flat list of file nodes when there are no folders after trimming', () => { + const tree = buildTree(['planning.md']); + expect(tree).toEqual([ + { kind: 'file', path: 'planning.md', label: 'planning.md' }, + ]); + }); + + it('strips the common directory prefix shared by all paths', () => { + const tree = buildTree([ + 'cockpit/planning/angular/app.config.ts', + 'cockpit/planning/python/graph.py', + ]); + // common prefix "cockpit/planning/" is removed; "angular" and "python" become top-level folders + expect(tree.map((n) => (n.kind === 'folder' ? n.label : n.label))).toEqual(['angular', 'python']); + }); + + it('compacts single-child folder chains into one row', () => { + const tree = buildTree([ + 'angular/src/app/planning.component.ts', + 'angular/src/app/app.config.ts', + ]); + // angular > src > app each have one child folder beneath them on the way down; + // they merge into a single "angular/src/app" folder row. + expect(tree).toHaveLength(1); + expect(tree[0]).toMatchObject({ kind: 'folder', label: 'angular/src/app' }); + expect((tree[0] as { children: TreeNode[] }).children.map((c) => c.label).sort()).toEqual([ + 'app.config.ts', + 'planning.component.ts', + ]); + }); + + it('keeps a folder distinct from its children when it has both a file and a subfolder', () => { + const tree = buildTree([ + 'angular/src/app/planning.component.ts', + 'angular/src/app/views/plan-checklist.component.ts', + ]); + // angular/src/app contains a file AND a "views" subfolder → does not merge with "views" + const top = tree[0] as { kind: 'folder'; label: string; children: TreeNode[] }; + expect(top.label).toBe('angular/src/app'); + expect(top.children).toHaveLength(2); + const labels = top.children.map((c) => c.label).sort(); + expect(labels).toEqual(['planning.component.ts', 'views']); + }); + + it('renders all files flat when no common prefix and only one segment each', () => { + const tree = buildTree(['a.ts', 'b.py']); + expect(tree).toEqual([ + { kind: 'file', path: 'a.ts', label: 'a.ts' }, + { kind: 'file', path: 'b.py', label: 'b.py' }, + ]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test cockpit -- src/components/code-mode/file-tree.utils.spec.ts` +Expected: FAIL — cannot resolve `./file-tree.utils`. + +- [ ] **Step 3: Implement `buildTree`** + +`apps/cockpit/src/components/code-mode/file-tree.utils.ts`: + +```ts +export type FileNode = { kind: 'file'; path: string; label: string }; +export type FolderNode = { kind: 'folder'; label: string; children: TreeNode[] }; +export type TreeNode = FileNode | FolderNode; + +function commonPrefixSegments(paths: readonly string[]): string[] { + if (paths.length === 0) return []; + const splits = paths.map((p) => p.split('/')); + const first = splits[0]; + const common: string[] = []; + for (let i = 0; i < first.length - 1; i++) { + const seg = first[i]; + if (splits.every((parts) => parts[i] === seg)) common.push(seg); + else break; + } + return common; +} + +function insert(root: FolderNode, segments: string[], fullPath: string): void { + let node = root; + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]; + let child = node.children.find((c): c is FolderNode => c.kind === 'folder' && c.label === seg); + if (!child) { + child = { kind: 'folder', label: seg, children: [] }; + node.children.push(child); + } + node = child; + } + const filename = segments[segments.length - 1]; + node.children.push({ kind: 'file', path: fullPath, label: filename }); +} + +function compact(nodes: TreeNode[]): TreeNode[] { + return nodes.map((node) => { + if (node.kind === 'file') return node; + let folder = node; + // Merge while this folder has exactly one child AND that child is a folder. + while (folder.children.length === 1 && folder.children[0].kind === 'folder') { + const only = folder.children[0]; + folder = { kind: 'folder', label: `${folder.label}/${only.label}`, children: only.children }; + } + return { ...folder, children: compact(folder.children) }; + }); +} + +export function buildTree(paths: readonly string[]): TreeNode[] { + if (paths.length === 0) return []; + const prefix = commonPrefixSegments(paths); + const trimmed = paths.map((p) => p.split('/').slice(prefix.length)); + const root: FolderNode = { kind: 'folder', label: '', children: [] }; + trimmed.forEach((segments, i) => insert(root, segments, paths[i])); + return compact(root.children); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test cockpit -- src/components/code-mode/file-tree.utils.spec.ts` +Expected: PASS — all 6 tests green. + +- [ ] **Step 5: Commit** + +```bash +git add apps/cockpit/src/components/code-mode/file-tree.utils.ts apps/cockpit/src/components/code-mode/file-tree.utils.spec.ts +git commit -m "feat(cockpit): add buildTree utility for Code-mode file tree" +``` + +--- + +## Task 3: `CodeMode` state migration (no UX change yet) + +Replace Radix `Tabs.defaultValue` with explicit `openPaths` + `activePath` state in `CodeMode`. No visible behavior change in this task — same files render, same active file, just with the state model that the file tree needs in Task 5. + +**Files:** +- Modify: `apps/cockpit/src/components/code-mode/code-mode.tsx` +- Modify: `apps/cockpit/src/components/code-mode/code-mode.spec.tsx` + +- [ ] **Step 1: Add a regression test that activating a tab via state change still works** + +In `apps/cockpit/src/components/code-mode/code-mode.spec.tsx`, append this new `it` block inside the existing `describe('CodeMode', …)`: + +```tsx +it('pre-opens all code, backend, and prompt files as tabs with the first code file active', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root!.render( + a

    ', + 'backend/graph.py': '
    g
    ', + }} + promptFiles={{ 'prompts/p.md': 'hello' }} + />, + ); + }); + + const tabLabels = Array.from(container.querySelectorAll('[role="tab"]')).map((t) => t.textContent); + expect(tabLabels).toEqual(['a.ts', 'graph.py', 'p.md']); + + // The first code file is active. + const active = container.querySelector('[role="tab"][data-state="active"]'); + expect(active?.textContent).toBe('a.ts'); +}); +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `npx nx test cockpit -- src/components/code-mode/code-mode.spec.tsx` +Expected: PASS for both this new test and the four existing ones. (Current behavior already satisfies the assertion — this is a regression guard for the upcoming state change.) + +- [ ] **Step 3: Migrate to explicit `openPaths` + `activePath` state** + +In `apps/cockpit/src/components/code-mode/code-mode.tsx`, replace the body of `CodeMode` (everything from `export function CodeMode(...)` through the closing `}`) with: + +```tsx +export function CodeMode({ entryTitle, codeAssetPaths, backendAssetPaths, codeFiles, promptFiles, capability }: CodeModeProps) { + const promptPaths = React.useMemo(() => Object.keys(promptFiles), [promptFiles]); + const allPaths = React.useMemo( + () => [...codeAssetPaths, ...backendAssetPaths, ...promptPaths], + [codeAssetPaths, backendAssetPaths, promptPaths], + ); + + const [openPaths, setOpenPaths] = React.useState(allPaths); + const [activePath, setActivePath] = React.useState(allPaths[0] ?? null); + + // If the capability changes (allPaths changes identity), reset open + active. + React.useEffect(() => { + setOpenPaths(allPaths); + setActivePath(allPaths[0] ?? null); + }, [allPaths]); + + if (allPaths.length === 0) { + return ( +
    +

    No files available for {entryTitle}.

    +
    + ); + } + + const isPromptPath = (path: string) => promptPaths.includes(path); + + return ( +
    + setActivePath(v)} + className="flex flex-col h-full" + > + + {openPaths.map((path) => ( + + {getTabLabel(path)} + + ))} + + + {openPaths.filter((p) => !isPromptPath(p)).map((path) => ( + +
    + +
    +
    + ))} + + {openPaths.filter(isPromptPath).map((path) => { + const content = promptFiles[path]; + return ( + +
    + {content ? ( +
    {content}
    + ) : ( +

    No content for {getTabLabel(path)}

    + )} +
    +
    + ); + })} +
    +
    + ); +} +``` + +(Key differences vs today: drop `Tabs.defaultValue`, use controlled `value` + `onValueChange`; render tabs from `openPaths` instead of separate `codeAssetPaths`/`backendAssetPaths`/`promptPaths` arrays; reset state when `allPaths` identity changes. The `TabsList`, `CodeFileContent`, and prompt-render markup stay identical to today.) + +- [ ] **Step 4: Run all code-mode tests** + +Run: `npx nx test cockpit -- src/components/code-mode/code-mode.spec.tsx` +Expected: PASS — the new test and all four existing ones (Shiki HTML render, fallback, prompt tabs, Copy analytics). + +- [ ] **Step 5: Run the full suite** + +Run: `npx nx test cockpit` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/cockpit/src/components/code-mode/code-mode.tsx apps/cockpit/src/components/code-mode/code-mode.spec.tsx +git commit -m "refactor(cockpit): controlled openPaths + activePath state in CodeMode" +``` + +--- + +## Task 4: `FileTree` presentational component (TDD) + +A pure-presentational component that renders the output of `buildTree` with folder-collapse interaction. No file-system access, no localStorage — just `{ paths, activePath, onSelect }` props. + +**Files:** +- Create: `apps/cockpit/src/components/code-mode/file-tree.tsx` +- Create: `apps/cockpit/src/components/code-mode/file-tree.spec.tsx` +- Modify: `apps/cockpit/src/app/cockpit.css` (file-tree styles) + +- [ ] **Step 1: Write the failing test** + +`apps/cockpit/src/components/code-mode/file-tree.spec.tsx`: + +```tsx +/** @vitest-environment jsdom */ +import React from 'react'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FileTree } from './file-tree'; + +describe('FileTree', () => { + let container: HTMLDivElement | undefined; + let root: ReturnType | undefined; + + afterEach(() => { + act(() => { root?.unmount(); }); + container?.remove(); + vi.clearAllMocks(); + }); + + function render(node: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { root!.render(node); }); + } + + it('renders a file row for every path', () => { + const onSelect = vi.fn(); + render( + + ); + + const labels = Array.from(container!.querySelectorAll('[data-file-row]')).map((el) => el.textContent); + expect(labels).toContain('planning.component.ts'); + expect(labels).toContain('graph.py'); + expect(labels).toContain('planning.md'); + }); + + it('marks the active file row with aria-current="true"', () => { + render( + {}} + /> + ); + + const active = container!.querySelector('[data-file-row][aria-current="true"]'); + expect(active?.textContent).toBe('b.py'); + }); + + it('emits onSelect with the file path when a file row is clicked', () => { + const onSelect = vi.fn(); + render( + + ); + + const row = container!.querySelector('[data-file-row]') as HTMLElement; + act(() => { row.click(); }); + + expect(onSelect).toHaveBeenCalledWith('angular/src/app/planning.component.ts'); + }); + + it('collapses a folder when its header is clicked and hides its children', () => { + render( + {}} + /> + ); + + // Folder "angular/src/app" is the only top-level row (compact-merged). + const folder = container!.querySelector('[data-folder-row]') as HTMLElement; + expect(folder.textContent).toContain('angular/src/app'); + expect(container!.querySelectorAll('[data-file-row]')).toHaveLength(2); + + act(() => { folder.click(); }); + + expect(container!.querySelectorAll('[data-file-row]')).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test cockpit -- src/components/code-mode/file-tree.spec.tsx` +Expected: FAIL — cannot resolve `./file-tree`. + +- [ ] **Step 3: Implement `FileTree`** + +`apps/cockpit/src/components/code-mode/file-tree.tsx`: + +```tsx +'use client'; + +import React from 'react'; +import { buildTree, type FolderNode, type TreeNode } from './file-tree.utils'; + +interface FileTreeProps { + paths: readonly string[]; + activePath: string | null; + onSelect: (path: string) => void; +} + +function langChip(label: string): string | null { + const dot = label.lastIndexOf('.'); + if (dot <= 0) return null; + return label.slice(dot + 1).toUpperCase(); +} + +export function FileTree({ paths, activePath, onSelect }: FileTreeProps) { + const tree = React.useMemo(() => buildTree(paths), [paths]); + const [collapsedFolders, setCollapsedFolders] = React.useState>(() => new Set()); + + const toggleFolder = React.useCallback((id: string) => { + setCollapsedFolders((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + return ( +
      + {tree.map((node, i) => ( + + ))} +
    + ); +} + +interface NodeProps { + node: TreeNode; + depth: number; + folderId: string; + activePath: string | null; + collapsedFolders: ReadonlySet; + onToggleFolder: (id: string) => void; + onSelect: (path: string) => void; +} + +function Node({ node, depth, folderId, activePath, collapsedFolders, onToggleFolder, onSelect }: NodeProps) { + if (node.kind === 'file') { + const chip = langChip(node.label); + const isActive = activePath === node.path; + return ( +
  • + +
  • + ); + } + + const folder = node as FolderNode; + const isCollapsed = collapsedFolders.has(folderId); + return ( +
  • + + {!isCollapsed ? ( +
      + {folder.children.map((child, i) => ( + + ))} +
    + ) : null} +
  • + ); +} +``` + +- [ ] **Step 4: Add tree styles to cockpit.css** + +Append to `apps/cockpit/src/app/cockpit.css`: + +```css +/* Code-mode file tree */ +.cockpit-file-tree { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.7; } +.cockpit-file-tree ul { list-style: none; padding: 0; margin: 0; } +.cockpit-file-tree__file, +.cockpit-file-tree__folder { + display: flex; align-items: center; gap: 0.4rem; width: 100%; + padding: 3px 0.75rem 3px 0.75rem; background: transparent; border: 0; text-align: left; cursor: pointer; + color: var(--ds-text-secondary); font-family: var(--font-mono), "JetBrains Mono", monospace; font-size: 12px; + border-left: 2px solid transparent; +} +.cockpit-file-tree__folder { color: var(--ds-text-muted); } +.cockpit-file-tree__caret { font-size: 9px; color: var(--ds-text-muted); width: 0.65rem; } +.cockpit-file-tree__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cockpit-file-tree__chip { + font-family: var(--font-mono), monospace; font-size: 9px; + padding: 1px 5px; border-radius: 3px; + background: var(--ds-accent-surface); color: var(--ds-accent); + opacity: 0.85; +} +.cockpit-file-tree__file:hover { color: var(--ds-text-primary); } +.cockpit-file-tree__file[aria-current="true"] { + background: var(--ds-accent-surface); + color: var(--ds-text-primary); + border-left-color: var(--ds-accent); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx nx test cockpit -- src/components/code-mode/file-tree.spec.tsx` +Expected: PASS — all 4 tests. + +- [ ] **Step 6: Run the full suite** + +Run: `npx nx test cockpit` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/cockpit/src/components/code-mode/file-tree.tsx apps/cockpit/src/components/code-mode/file-tree.spec.tsx apps/cockpit/src/app/cockpit.css +git commit -m "feat(cockpit): FileTree component with folder collapse and active row" +``` + +--- + +## Task 5: Mount `FileTree` in Code mode with responsive layout + +Place the tree to the left of the tab strip at `lg:` widths, hidden below. Wire `onSelect` to the existing `openPaths`/`activePath` handlers (activates if already open, opens if not). + +**Files:** +- Modify: `apps/cockpit/src/components/code-mode/code-mode.tsx` +- Modify: `apps/cockpit/src/components/code-mode/code-mode.spec.tsx` + +- [ ] **Step 1: Write the failing test (tree-click opens a not-yet-open file)** + +Append inside the existing `describe('CodeMode', …)` in `code-mode.spec.tsx`: + +```tsx +it('opens a closed file and activates it when the tree row is clicked', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root!.render( + a', + 'src/b.ts': '
    b
    ', + }} + promptFiles={{}} + />, + ); + }); + + // Simulate the close (×) behaviour landing in Task 6 by directly removing the tab from state via the tree: + // for now, just assert that clicking the tree row for b.ts activates b.ts (which is already open). + const bRow = Array.from(container.querySelectorAll('[data-file-row]')).find( + (el) => el.textContent === 'b.ts', + ) as HTMLElement; + expect(bRow).toBeDefined(); + + act(() => { bRow.click(); }); + + const active = container.querySelector('[role="tab"][data-state="active"]'); + expect(active?.textContent).toBe('b.ts'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test cockpit -- src/components/code-mode/code-mode.spec.tsx` +Expected: FAIL — no `[data-file-row]` element rendered (FileTree not yet mounted in CodeMode). + +- [ ] **Step 3: Mount `FileTree` in `CodeMode`** + +In `apps/cockpit/src/components/code-mode/code-mode.tsx`: + +Add the FileTree import near the top: +```tsx +import { FileTree } from './file-tree'; +``` + +Add the select handler inside `CodeMode` (right after the existing `useEffect` reset): + +```tsx +const handleSelect = React.useCallback((path: string) => { + setOpenPaths((prev) => (prev.includes(path) ? prev : [...prev, path])); + setActivePath(path); +}, []); +``` + +Replace the outer `
    ` opening tag and its single `` child with a responsive split: + +```tsx +return ( +
    + + +
    + setActivePath(v)} + className="flex flex-col h-full" + > + {/* existing TabsList + TabsContent panels go here unchanged */} + +
    +
    +); +``` + +The TabsList + TabsContent panels are unchanged from Task 3 — just moved inside the new `
    ` wrapper. + +- [ ] **Step 4: Run the updated spec** + +Run: `npx nx test cockpit -- src/components/code-mode/code-mode.spec.tsx` +Expected: PASS — the new tree-click test and all earlier ones. + +- [ ] **Step 5: Run the full suite** + +Run: `npx nx test cockpit` +Expected: PASS. + +- [ ] **Step 6: Browser verify** + +Reload the cockpit Code mode at a viewport ≥1024px. The tree appears left of the tabs; clicking a tree row activates the matching tab. Resize below 1024px → tree disappears, tabs span full width. Toggle theme — colors flip correctly. + +- [ ] **Step 7: Commit** + +```bash +git add apps/cockpit/src/components/code-mode/code-mode.tsx apps/cockpit/src/components/code-mode/code-mode.spec.tsx +git commit -m "feat(cockpit): mount FileTree in Code mode with lg: responsive split" +``` + +--- + +## Task 6: Tab close (×) + last-tab empty state + +Add a close button to each tab trigger (visible on hover); closing removes the path from `openPaths` and activates the left neighbor (or the new leftmost if the closed tab was first). When the last tab closes, the content area shows a "select a file from the tree" empty state. + +**Files:** +- Modify: `apps/cockpit/src/components/code-mode/code-mode.tsx` +- Modify: `apps/cockpit/src/components/code-mode/code-mode.spec.tsx` +- Modify: `apps/cockpit/src/app/cockpit.css` (close-button styles) + +- [ ] **Step 1: Write the failing tests** + +Append inside `describe('CodeMode', …)`: + +```tsx +it('closes a tab and activates its left neighbor', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root!.render( + a', + 'src/b.ts': '
    b
    ', + 'src/c.ts': '
    c
    ', + }} + promptFiles={{}} + />, + ); + }); + + // Activate b.ts, then close it. + const bTab = Array.from(container.querySelectorAll('[role="tab"]')).find( + (el) => el.textContent?.startsWith('b.ts'), + ) as HTMLElement; + act(() => { + bTab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })); + }); + + const closeBtn = container.querySelector('[role="tab"][data-state="active"] [data-tab-close]') as HTMLElement; + expect(closeBtn).not.toBeNull(); + act(() => { closeBtn.click(); }); + + const tabs = Array.from(container.querySelectorAll('[role="tab"]')).map((t) => + (t.textContent ?? '').replace(/×/g, '').trim(), + ); + expect(tabs).toEqual(['a.ts', 'c.ts']); + + const active = container.querySelector('[role="tab"][data-state="active"]'); + expect((active?.textContent ?? '').startsWith('a.ts')).toBe(true); +}); + +it('shows the empty state after the last tab is closed', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root!.render( + x' }} + promptFiles={{}} + />, + ); + }); + + const closeBtn = container.querySelector('[role="tab"] [data-tab-close]') as HTMLElement; + act(() => { closeBtn.click(); }); + + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(0); + expect(container.textContent).toContain('Select a file from the tree'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test cockpit -- src/components/code-mode/code-mode.spec.tsx` +Expected: FAIL — `[data-tab-close]` not present. + +- [ ] **Step 3: Add the close handler and the close button** + +In `apps/cockpit/src/components/code-mode/code-mode.tsx`, add the handler near `handleSelect`: + +```tsx +const handleClose = React.useCallback((path: string) => { + setOpenPaths((prev) => { + const idx = prev.indexOf(path); + if (idx < 0) return prev; + const next = prev.filter((p) => p !== path); + setActivePath((current) => { + if (current !== path) return current; + if (next.length === 0) return null; + // Activate the left neighbor; if the closed tab was leftmost, activate the new leftmost. + const neighborIdx = Math.max(0, idx - 1); + return next[neighborIdx] ?? next[0]; + }); + return next; + }); +}, []); +``` + +Replace the `TabsTrigger` element inside the `TabsList` map with a version that includes the close button: + +```tsx + + {getTabLabel(path)} + { e.stopPropagation(); }} + onClick={(e) => { e.stopPropagation(); handleClose(path); }} + className="cockpit-tab-trigger__close" + >× + +``` + +Below the `` in the same `
    `, add an empty-state fallback that renders when `activePath === null`: + +```tsx +{activePath === null ? ( +
    +

    Select a file from the tree to begin.

    +
    +) : null} +``` + +Place this *outside* the `` element but inside the wrapping `
    ` — it shows only when `openPaths` is empty so the Radix `` won't render content for a missing `value`. + +- [ ] **Step 4: Add close-button styles** + +Append to `apps/cockpit/src/app/cockpit.css`: + +```css +/* Tab close (×) on Code-mode tabs */ +.cockpit-tab-trigger { display: inline-flex; align-items: center; gap: 0.4rem; } +.cockpit-tab-trigger__close { + display: inline-flex; align-items: center; justify-content: center; + width: 0.95rem; height: 0.95rem; border-radius: 0.2rem; + color: var(--ds-text-muted); font-size: 0.85rem; line-height: 1; + opacity: 0; cursor: pointer; +} +.cockpit-tab-trigger:hover .cockpit-tab-trigger__close, +.cockpit-tab-trigger[data-state="active"] .cockpit-tab-trigger__close { opacity: 1; } +.cockpit-tab-trigger__close:hover { background: var(--ds-accent-surface); color: var(--ds-text-primary); } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx nx test cockpit -- src/components/code-mode/code-mode.spec.tsx` +Expected: PASS — both new tests and all earlier ones. + +- [ ] **Step 6: Run the full suite** + +Run: `npx nx test cockpit` +Expected: PASS. + +- [ ] **Step 7: Browser verify** + +Reload Code mode. Hover a tab → × appears. Click × on a non-active tab → tab disappears, active unchanged. Click × on the active tab → left neighbor becomes active. Close every tab → empty-state message renders. Click a file in the tree → it opens and becomes active. + +- [ ] **Step 8: Commit** + +```bash +git add apps/cockpit/src/components/code-mode/code-mode.tsx apps/cockpit/src/components/code-mode/code-mode.spec.tsx apps/cockpit/src/app/cockpit.css +git commit -m "feat(cockpit): tab close button + last-tab empty state in Code mode" +``` + +--- + +## Task 7: Tree collapse toggle with localStorage persistence + +Two chevron buttons collapse/expand the tree. State persists in `localStorage` under `cockpit:codeTree:collapsed`. When collapsed, the tree column hides and the tab strip widens; an expand chevron sits flush at the tab strip's left edge. + +**Files:** +- Modify: `apps/cockpit/src/components/code-mode/code-mode.tsx` +- Modify: `apps/cockpit/src/app/cockpit.css` + +- [ ] **Step 1: Add the collapse state and persistence** + +In `apps/cockpit/src/components/code-mode/code-mode.tsx`, add state + hydration just below the existing `openPaths`/`activePath` declarations: + +```tsx +const [treeCollapsed, setTreeCollapsed] = React.useState(false); + +React.useEffect(() => { + try { + if (typeof window !== 'undefined' && window.localStorage.getItem('cockpit:codeTree:collapsed') === '1') { + setTreeCollapsed(true); + } + } catch { + /* localStorage unavailable — leave default */ + } +}, []); + +const toggleTreeCollapsed = React.useCallback(() => { + setTreeCollapsed((prev) => { + const next = !prev; + try { + if (typeof window !== 'undefined') { + window.localStorage.setItem('cockpit:codeTree:collapsed', next ? '1' : '0'); + } + } catch { + /* ignore */ + } + return next; + }); +}, []); +``` + +- [ ] **Step 2: Wire the chevron buttons** + +Change the `