From c02391f0e5e1a978602bfdb6b219b5e030860f3c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 3 Jun 2026 10:33:57 -0700 Subject: [PATCH 01/14] 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) --- .../2026-06-03-code-mode-file-tree-design.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-03-code-mode-file-tree-design.md diff --git a/docs/superpowers/specs/2026-06-03-code-mode-file-tree-design.md b/docs/superpowers/specs/2026-06-03-code-mode-file-tree-design.md new file mode 100644 index 00000000..af55fe6f --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-code-mode-file-tree-design.md @@ -0,0 +1,130 @@ +# Code Mode File Tree — Design Spec + +**Date:** 2026-06-03 +**Status:** Design — pending review +**Scope:** `apps/cockpit` Code-mode UI only. No changes to `libs/*`, `content-bundle.ts`, or `cockpit-registry`. + +## Goal + +Give Code mode a VS Code / Zed–style **file tree on the left of the tab strip** at desktop widths, so multi-file capabilities (Angular + Python + prompt) are oriented at a glance, while preserving the current tab interaction for fast file switching. Below the responsive breakpoint the tree auto-hides and Code mode falls back to today's tabs-only layout. The user can also collapse the tree explicitly at any width. + +This is purely a layout enrichment of an existing mode — no new data model, no new content bundle, no new dependencies. + +## Direction (validated via mockups) + +Chosen option: **B — tree opens tabs (VS Code / Zed style)**, refined with: + +- **All files from the content bundle pre-opened as tabs on first load.** Matches today's behavior ("everything is visible") and adds open/close semantics only as a *capability*, not a forced workflow. The first `codeAssetPath` is the initial active tab. +- **No persistence.** Each capability navigation resets to all-tabs-open. Closed-tab state is not stored in `localStorage` or cookies. (Collapse state of the tree itself *is* persisted — see §4.) +- **Tree on the LEFT of the tabs**, full-height for the Code-mode pane. Tabs and code area both sit right of the tree. + +## Layout & breakpoints + +``` +┌─────────────────────────────────────────────────────────┐ +│ Tree header │ Tab strip │ +│ (collapse chevron, label) │ (file tabs, optional × ) │ +├──────────────────────────────┼──────────────────────────┤ +│ file-tree rows │ Code block (active tab) │ +│ folders + files │ │ +│ │ │ +└──────────────────────────────┴──────────────────────────┘ +``` + +- **`lg:` (≥1024px):** tree visible by default at `width: 220px`, sharing the Code-mode pane's full height. The tree's right border meets the tab-strip's bottom border so the two read as one IDE-like surface. +- **<`lg:`:** tree auto-hidden. Tab strip + code area take the full pane width — i.e. today's layout. No mobile overlay (the cockpit shell's main sidebar already handles narrow-viewport navigation; adding a second overlay would be noisy). +- **Explicit collapse toggle:** chevron buttons on the tree header *and* on the tab-strip's left edge (visible only when the tree is expanded vs. collapsed respectively). State stored in `localStorage` under `cockpit:codeTree:collapsed`. The Tailwind `lg:` media query and the explicit toggle compose: a user can collapse at any width, and the tree only auto-shows at `lg:` when not explicitly collapsed. +- **Transition:** 200ms CSS width transition on the tree, so the collapse/expand isn't a layout jump. + +## Tree content & organization + +The tree shows only files the content bundle already provides — `codeAssetPaths` + `backendAssetPaths` + `promptFiles`. No new file-system walking; no `package.json`/`tsconfig.json` noise. + +Each path is **trimmed of the capability's common prefix** (e.g. `cockpit/deep-agents/planning/`), then organized as folder/file rows grouped under their language root: + +``` +▾ angular/src/app + planning.component.ts TS + app.config.ts TS + ▾ views + plan-checklist.component.ts TS +▾ python/src + graph.py PY +▾ prompts + planning.md MD +``` + +Rules: + +- **Folder rows are click-collapsible.** Default expanded. Collapse state is per-folder in component state (not persisted). +- **File rows render the existing language chip** (`TS` / `PY` / `MD` / fallback) in `.doc-codeblock__lang` style, right-aligned in the row. +- **Active file** gets a 2px `--ds-accent` left border, `--ds-text-primary` text, and a subtle accent-surface row background. +- **Common-prefix trimming** is per-capability: the longest path segment shared by all paths in the bundle. If trimming leaves the tree with only loose leaves (e.g. just `planning.md` and `graph.py`), display them as a flat list under a `files` group header rather than synthesizing folders. + +## Tabs & lifecycle + +The Code-mode component owns explicit `openPaths` + `activePath` state (replacing today's `Tabs.defaultValue` usage). Radix `Tabs` continues to provide the tab strip itself. + +- **Initial state:** on capability load, `openPaths = [...codeAssetPaths, ...backendAssetPaths, ...promptPaths]` (preserving today's order) and `activePath = openPaths[0]`. +- **Click a tree row:** + - If `path` is already in `openPaths` → set `activePath = path` (just activate). + - If not → push to `openPaths` and set `activePath = path`. +- **Tab close (×) button:** appears on hover. Clicking removes the path from `openPaths`. If the closed tab was active, activate its left neighbor; if it was the leftmost, activate the new leftmost. +- **Closing the last tab:** code area shows an empty state ("Select a file from the tree to begin") with a faint chevron pointing left toward the tree. The tab strip becomes empty (just the collapse chevron). +- **No keyboard shortcuts** in this iteration. (Cmd/Ctrl+W to close, etc., can land in a follow-up.) + +## Visual integration + +Tree and tab strip share the cockpit's chat-aligned dark palette and existing tokens. No new colors: + +- Tree background: `var(--ds-surface)` with the same right border (`var(--ds-border)`) the sidebar uses. +- Tree header (where the collapse chevron lives): same height as the tab strip (~34px), `var(--ds-surface-tinted)` background, `border-bottom: 1px solid var(--ds-border)`. +- File rows: mono font for the filename (`var(--font-mono)`), 12px size, line-height 1.7 — same scale as Docs prose code chips. +- Lang chip on rows: reuses `.doc-codeblock__lang` styling. +- Active tree row: `border-left: 2px solid var(--ds-accent)`; row background `var(--ds-accent-surface)`; text `var(--ds-text-primary)`. +- Hover tree row: text `var(--ds-text-primary)`, no background change (subtle). +- Folder chevron: a 9px caret in `var(--ds-text-muted)`, rotates 90° when collapsed. +- Tab strip and code block keep the styles from the merged redesign (`.doc-codeblock`, the `.cockpit-prose--code` 56rem wrapper for the code body, etc.) — *only* the surrounding tree is new. + +When the tree is collapsed, an expand chevron sits flush at the left edge of the tab strip in the same color treatment as the active-tab accent, so it reads as a discoverable toggle rather than dead space. + +## Components & file structure + +| File | Change | Responsibility | +|------|--------|----------------| +| `apps/cockpit/src/components/code-mode/file-tree.tsx` | **NEW** | Pure presentational tree. Takes `paths`, `activePath`, emits `onSelect(path)`. Owns folder-collapse state internally. | +| `apps/cockpit/src/components/code-mode/file-tree.spec.tsx` | **NEW** | Renders the tree from a known path list; click → emits `onSelect` with the right path; folder header click toggles its children's visibility. | +| `apps/cockpit/src/components/code-mode/code-mode.tsx` | Modify | Adds `openPaths` + `activePath` state, renders the `` inside a responsive `lg:grid-cols-[220px_1fr]` wrapper, threads `onSelect` from tree to state, adds the collapse-chevron buttons, hooks `localStorage` for tree-collapsed. The existing `CodeFileContent` and Radix `Tabs` structure stays. | +| `apps/cockpit/src/components/code-mode/code-mode.spec.tsx` | Modify | Adds assertions for (a) all-files-pre-opened initial state, (b) closing a tab removes it from the strip and selects the neighbor, (c) tree click activates an existing tab without duplicating, (d) closing the last tab renders the empty state. | + +Internal helper to keep `file-tree.tsx` small: a pure `buildTree(paths: string[])` function (also in `file-tree.tsx` or in a `file-tree.utils.ts` sibling if it grows >30 lines) that returns a discriminated `Folder | File` node array used by the renderer. This makes the prefix-trimming and folder-grouping testable in isolation. + +## Out of scope + +- File-system walking to surface package.json / tsconfig.json / additional adjacent files. +- Drag-to-reorder tabs. +- Keyboard shortcuts (Cmd+W close, Cmd+P quick-open, etc.). +- Cross-capability persistence of which tabs were closed. +- Resizable tree width (drag-to-resize). Fixed 220px. +- Mobile / overlay tree on ` Date: Wed, 3 Jun 2026 10:46:22 -0700 Subject: [PATCH 02/14] docs(plan): code-mode file tree implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-06-03-code-mode-file-tree.md | 1169 +++++++++++++++++ 1 file changed, 1169 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-code-mode-file-tree.md 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 `
    - setActivePath(v)} - className="flex flex-col h-full" - > - - {openPaths.map((path) => ( - - {getTabLabel(path)} - { e.stopPropagation(); }} - onClick={(e) => { 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 ( + {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) => (
    - {content ? ( -
    {content}
    - ) : ( -

    No content for {getTabLabel(path)}

    - )} +
    - ); - })} -
    - {activePath === null ? ( -
    -

    Select a file from the tree to begin.

    -
    - ) : null} + ))} + + {openPaths.filter(isPromptPath).map((path) => { + const content = promptFiles[path]; + return ( + +
    + {content ? ( +
    {content}
    + ) : ( +

    No content for {getTabLabel(path)}

    + )} +
    +
    + ); + })} +
    + )}
    ); From 1e511d057b8cbcef6450eea16cf0848d31741f59 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 3 Jun 2026 18:37:44 -0700 Subject: [PATCH 13/14] feat(cockpit): persistent collapse toggle for Code-mode file tree Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/code-mode/code-mode.tsx | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/cockpit/src/components/code-mode/code-mode.tsx b/apps/cockpit/src/components/code-mode/code-mode.tsx index 3fd5d764..fbe73a38 100644 --- a/apps/cockpit/src/components/code-mode/code-mode.tsx +++ b/apps/cockpit/src/components/code-mode/code-mode.tsx @@ -63,6 +63,32 @@ export function CodeMode({ entryTitle, codeAssetPaths, backendAssetPaths, codeFi const [openPaths, setOpenPaths] = React.useState(allPaths); const [activePath, setActivePath] = React.useState(allPaths[0] ?? null); + 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; + }); + }, []); + // If the capability changes (allPaths changes identity), reset open + active. React.useEffect(() => { setOpenPaths(allPaths); @@ -102,16 +128,33 @@ export function CodeMode({ entryTitle, codeAssetPaths, backendAssetPaths, codeFi const isPromptPath = (path: string) => promptPaths.includes(path); return ( -
    +
    + {treeCollapsed ? ( + + ) : null} {openPaths.length === 0 || activePath === null ? (

    Select a file from the tree to begin.

    From 164c41db00d1df86118144e14a1c5aad061790d9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 3 Jun 2026 19:26:18 -0700 Subject: [PATCH 14/14] fix(cockpit): suppress collapse animation on hydration + flex layout for smooth transition Co-Authored-By: Claude Sonnet 4.6 --- apps/cockpit/src/components/code-mode/code-mode.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/cockpit/src/components/code-mode/code-mode.tsx b/apps/cockpit/src/components/code-mode/code-mode.tsx index fbe73a38..5e013fc6 100644 --- a/apps/cockpit/src/components/code-mode/code-mode.tsx +++ b/apps/cockpit/src/components/code-mode/code-mode.tsx @@ -64,6 +64,7 @@ export function CodeMode({ entryTitle, codeAssetPaths, backendAssetPaths, codeFi const [activePath, setActivePath] = React.useState(allPaths[0] ?? null); const [treeCollapsed, setTreeCollapsed] = React.useState(false); + const [mounted, setMounted] = React.useState(false); React.useEffect(() => { try { @@ -71,8 +72,12 @@ export function CodeMode({ entryTitle, codeAssetPaths, backendAssetPaths, codeFi setTreeCollapsed(true); } } catch { - /* localStorage unavailable — leave default */ + /* 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(() => { @@ -128,10 +133,10 @@ export function CodeMode({ entryTitle, codeAssetPaths, backendAssetPaths, codeFi const isPromptPath = (path: string) => promptPaths.includes(path); return ( -
    +
    -
    +
    {treeCollapsed ? (