# Workspace UI Rebuild 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:** Rebuild the Workspace page to the reference screen's legibility — card grid, large pipeline, collapsible console — and fill the two data gaps that make lanes look emptier than they are (git facts, expiring detection). **Architecture:** Design doc: `docs/superpowers/specs/2026-07-29-workspace-ui-design.md` — read it once before Task 1. Two server tasks land first because the client renders what they produce: detection expiry in `recordDetection`, and a read-only `GET /api/lanes/:id/git` reusing `worktree.js`'s existing `git()` and `statusCounts()`. Then the card is rebuilt, then the page shell around it. **Tech Stack:** Node 18+, Express, better-sqlite3, `node:test` (server); React 18 + TypeScript + Vite + Tailwind, Vitest + Testing Library (client). ## Global Constraints - Branch: `feat/workspace-ui`, cut from the head of `feat/workspace-page`. Never work on `master`. - Every `.js/.ts/.tsx` created or modified MUST start with a file overview comment plus the exact line `@author Nguyễn Ngọc Trí Vĩ `. Verify with `bash .claude/skills/file-headers/scripts/check-headers.sh` (must exit 0). - **Detection never writes `lanes.stage`**, and **an inferred node never renders `done`.** Both are load-bearing invariants from sub-project B; a change that lets either slip is a failed task regardless of what else it achieves. - **No git command may be built as a shell string.** `execFile` with an argv array only, through the existing `git()` wrapper in `server/lib/worktree.js` — it scrubs the inherited `GIT_*` environment, and that scrub exists because a real bug was traced to it. - The destroy guard (`assertDestroyable`) and the preflight/`expect` echo are not touched by this plan. - `GET /api/lanes` stays free of git subprocesses. Git facts are their own endpoint. - Schema changes are additive with a per-column probe (`try { SELECT col } catch { ALTER }`). - Server CommonJS. No new npm dependencies. Server tests `node:test` + `node:assert/strict`; client tests Vitest + Testing Library. Exact-value assertions, no bare sleeps. - i18n strings in all four locales (`en`, `zh`, `vi`, `ko`), genuinely translated — no English copied into the other three. - **Node 24 is required to run the suites.** Node 25 breaks 20 client tests (global `localStorage`) and 6 server tests (better-sqlite3 ABI). Run with `PATH="$HOME/.nvm/versions/node/v24.14.1/bin:$PATH"`. - The pre-commit hook runs Prettier plus both suites and takes minutes. Let it finish. NEVER `--no-verify`. - Baseline at branch point: 906 server tests, 347 client tests, all passing. Each task leaves `git status --short` empty. --- ## Task 1 (D1): detection expires **Files:** Modify `server/lib/lanes.js`, `server/__tests__/lanes-lib.test.js`. **Produces:** `recordDetection` gains a staleness window. When the lane's `detected_at` is older than `DETECTION_TTL_MS` (read from `process.env`, default `1_800_000`), the forward-only comparison against `detected_stage` is skipped entirely and a fresh detection is accepted even if it sits behind. Inside the window, behaviour is byte-for-byte what it is today. The declared-wins rule is NOT affected by the window: a lane whose declared stage leads still refuses the detection, stale or not. Only the detected-vs-detected comparison expires. A lane with `detected_stage` set but `detected_at` NULL (rows written before this column was populated) is treated as stale — an unknown age cannot be proven fresh. - [ ] **Step 1: write the failing tests** in `lanes-lib.test.js`: a backward detection inside the window still returns `behind-detected` and writes nothing; the same backward detection with `detected_at` set beyond the TTL is written and returns `{written: true}`; a stale detection that is behind the DECLARED stage still returns `behind-declared`; a lane with `detected_stage` set and `detected_at` NULL accepts a backward detection; the TTL reads from `DETECTION_TTL_MS`. Set `detected_at` by writing the column directly in the fixture — do not sleep. - [ ] **Step 2: run, confirm they fail** — `node --test server/__tests__/lanes-lib.test.js`. - [ ] **Step 3: implement.** One added branch in `recordDetection`. Do not touch `withDetected`, `clearLane`, or the payload shape. - [ ] **Step 4: run, confirm they pass;** then the full server suite. - [ ] **Step 5:** header audit, commit — `feat(lanes): expire a stale detection so a lane can move backwards between sessions`. --- ## Task 2 (D2): the detected signal says what matched **Files:** Modify `server/lib/stage-detect.js`, `server/__tests__/stage-detect.test.js`. **Produces:** `detect()` returns a `signal` built from the span the rule's regex actually matched plus surrounding context, instead of the whole flattened input. A rule with no `match` (it fired on the tool name alone) keeps today's behaviour: the flattened input, capped. The existing `capSignal` cap (120 chars, whitespace collapsed, ellipsis) still applies last. Concretely: `Bash` with `cd /very/long/path && npm run test:server 2>&1 | tail -5` currently yields the whole string; it must yield a signal containing `npm run test:server` and not the `cd` prefix. `detect()` must remain total — it never throws on any input. - [ ] **Step 1: write the failing tests:** the Bash example above yields a signal containing `npm run test:server` and not `/very/long/path`; a rule with no `match` still yields the flattened input; a signal longer than the cap is still capped with the ellipsis; a matched span at the very start and at the very end of the input both survive; `detect` still returns null for an unmentioned tool. - [ ] **Step 2: run, confirm they fail.** - [ ] **Step 3: implement.** - [ ] **Step 4: run, confirm they pass;** then the full server suite. - [ ] **Step 5:** header audit, commit — `feat(lanes): report the matched span as the detection signal`. --- ## Task 3 (D3): `gitFacts()` in the worktree library **Files:** Modify `server/lib/worktree.js`; modify `server/__tests__/worktree.test.js`. **Produces:** `gitFacts(dir)` returning `{branch, head, subject, dirty, untracked}`. It reuses the EXISTING `git()` wrapper and `statusCounts(dir)` in the same file — do NOT add a second subprocess helper and do NOT build any command as a shell string. `branch` comes from `rev-parse --abbrev-ref HEAD`, `subject` from `log -1 --format=%s`, and `head`/`dirty`/`untracked` come from `statusCounts`. It throws nothing the caller must catch beyond what `git()` already throws; the route in D4 decides what a failure means. No route, no HTTP, no OpenAPI in this task. - [ ] **Step 1: write the failing tests** against a real temporary git repo fixture (`git init`, one commit, then one modified tracked file and one untracked file): the branch name, the short head matching `rev-parse --short HEAD`, the exact commit subject, `dirty: 1`, `untracked: 1`. Also: a detached HEAD yields a `branch` of `HEAD` (assert the exact value the command returns, do not invent one); a repo whose only commit has a subject containing spaces returns it whole. - [ ] **Step 2: run, confirm they fail.** - [ ] **Step 3: implement.** - [ ] **Step 4:** full server suite. - [ ] **Step 5:** header audit, commit — `feat(lanes): read branch, head, subject and working-tree counts from a worktree`. --- ## Task 4 (D4): the `GET /api/lanes/:id/git` route **Files:** Modify `server/routes/lanes.js`, `server/openapi-extra/lanes.js` (+ regenerate `openapi.yaml`); modify `server/__tests__/lanes-api.test.js`. **Produces:** `GET /api/lanes/:id/git` → `200 {available: true, ...facts}` for a git worktree; `200 {available: false}` when the lane's `cwd` is missing, is not a git repo, or git fails for any reason. A missing lane is `404`. It is a READ endpoint: no same-origin guard (that guard is for the destructive actions), and it must never mutate a lane. Register the route **before** the `/:id/:action` catch-all, the same way `/ensure` had to be — otherwise `git` is swallowed as an action name. State in your report that you checked the ordering and how. - [ ] **Step 1: write the failing tests:** a lane pointing at a real temporary git repo returns the branch, short head, subject, `dirty` and `untracked`; a lane whose `cwd` is a plain directory returns `{available: false}` with HTTP 200; a lane whose `cwd` does not exist returns `{available: false}`; an unknown lane id returns 404; the route is NOT shadowed by `/:id/:action` — assert the response body shape, not merely the status. - [ ] **Step 2: run, confirm they fail.** - [ ] **Step 3: implement.** - [ ] **Step 4:** full server suite; `node scripts/generate-openapi-yaml.js` then confirm `git diff openapi.yaml` shows only the added path. - [ ] **Step 5:** header audit, commit — `feat(lanes): expose a lane's git facts over the API`. --- ## Task 5 (D5): client API + types for git facts **Files:** Modify `client/src/lib/api.ts`, `client/src/lib/types.ts`; modify the matching api test if one exists, else add the assertion to `client/src/lib/__tests__/`. **Produces:** `api.lanes.git(id)` calling `GET /api/lanes/:id/git`, and a `LaneGitFacts` type (`{available: true, branch, head, subject, dirty, untracked} | {available: false}`) exported from `client/src/lib/types.ts`. Nothing renders it yet. Keep the discriminated union — a caller must be forced to check `available` before reading `branch`. Do not make the fields optional on one flat type. - [ ] **Step 1: write the failing test:** `api.lanes.git(3)` requests exactly `/api/lanes/3/git` with method GET, and returns the parsed body. - [ ] **Step 2: run, confirm it fails.** - [ ] **Step 3: implement.** - [ ] **Step 4:** `npm run test:client`, `npm run build`. - [ ] **Step 5:** header audit, commit — `feat(lanes): client binding for a lane's git facts`. --- ## Task 6 (D6): rebuild the lane card's own fields **Files:** Modify `client/src/components/lanes/LaneCard.tsx`, `client/src/i18n/locales/{en,zh,vi,ko}/lanes.json`; create `client/src/components/lanes/__tests__/LaneCard.test.tsx`. **Produces:** the card laid out per the design doc's table, using only fields the lane payload ALREADY carries: header row (`LANE `, liveness dot, status), title, declared-stage chip with progress bar / `%` / time-on-stage, the dashed-amber `auto: ` chip carrying `detected_signal` as its tooltip, the kind and CI tags, the needs-you banner, and the action row. **No git block in this task** — that is D7. Do not call `api.lanes.git` here. The existing action wiring and `DestructiveLaneModal` usage are preserved exactly: `reset` and `remove` keep their preflight and `expect` echo. Every string goes through i18n in all four locales, genuinely translated. The card shows a chip, never a node state — it must not render a detected stage as done. - [ ] **Step 1: write the failing tests** in `LaneCard.test.tsx`: every field of a fully-populated fixture lane renders with its exact value; the `auto` chip appears only when the detected stage leads the declared one, and its `title` contains the signal; a lane whose detected stage equals or trails the declared one shows NO auto chip; clicking `reset` opens the destructive modal rather than firing the action directly; the plain action callbacks fire with the right action name. - [ ] **Step 2: run, confirm they fail** — `cd client && npx vitest run src/components/lanes/__tests__/LaneCard.test.tsx`. - [ ] **Step 3: implement.** - [ ] **Step 4:** `npm run test:client`, `npm run build`. - [ ] **Step 5:** header audit, commit — `feat(lanes): rebuild the lane card for legibility`. --- ## Task 7 (D7): the card's git block **Files:** Modify `client/src/components/lanes/LaneCard.tsx`, `client/src/i18n/locales/{en,zh,vi,ko}/lanes.json`; modify `client/src/components/lanes/__tests__/LaneCard.test.tsx`. **Produces:** the card fetches its own facts through `api.lanes.git(lane.id)` on mount and every 30s, and renders a git row — branch, short head, commit subject, and the dirty/untracked counts. It renders the rest of the card unchanged while the facts are still loading and whenever `available` is false. A failed request is silent: no error banner, no retry storm. The interval must be cleared on unmount. - [ ] **Step 1: write the failing tests:** the git row renders each fact from a mocked `available: true` response; an `available: false` response renders the card with NO git row and no error; a rejected request renders the card with no git row and no error; unmounting clears the interval (assert the timer count, or that no further request is made after unmount). - [ ] **Step 2: run, confirm they fail.** - [ ] **Step 3: implement.** - [ ] **Step 4:** `npm run test:client`, `npm run build`. - [ ] **Step 5:** header audit, commit — `feat(lanes): show a lane's branch and working-tree state on its card`. --- ## Task 8 (D8): the page header and the card grid **Files:** Modify `client/src/pages/Workspace.tsx`, `client/src/i18n/locales/{en,zh,vi,ko}/lanes.json`; modify `client/src/pages/__tests__/Workspace.test.tsx`. **Produces:** the header bar — page title, the four counters (`lanes`, `running`, `needs you`, `dead`) from the API's `counts`, and the Add-lane control — and the responsive card grid (1 column, 2 at `md`, 3 at `xl`) replacing today's horizontal lane strip. Selecting a card still drives the same `selectedLaneId` state it does now. Do NOT touch the console or the pipeline panel in this task; leave them exactly where they are, below the grid, however they currently render. - [ ] **Step 1: write the failing tests:** the four counters render the exact values from the API's `counts`; every lane in the response gets a card; clicking a card sets it selected (assert an observable consequence, e.g. the pipeline panel's lane, not an internal state variable). - [ ] **Step 2: run, confirm they fail.** - [ ] **Step 3: implement.** - [ ] **Step 4:** `npm run test:client`, `npm run build`. The screens snapshot WILL change — read the diff, confirm it is only the header and grid, then regenerate. - [ ] **Step 5:** header audit, commit — `feat(lanes): lane grid and counters in the Workspace header`. --- ## Task 9 (D9): the selected-lane detail panel **Files:** Modify `client/src/pages/Workspace.tsx`, `client/src/i18n/locales/{en,zh,vi,ko}/lanes.json`; modify `client/src/pages/__tests__/Workspace.test.tsx`. **Produces:** the detail panel between the header and the grid: the selected lane's title, its declared stage and — when detection leads — the inferred one, a large `PipelineMap`, and the legend naming the five node states plus the dashed-amber inferred treatment. **Do not change `PipelineMap` itself** — not its node-state logic, not its props. This task places and sizes it. - [ ] **Step 1: write the failing tests:** the panel shows the selected lane's title and declared stage; selecting a different card switches the panel's pipeline; **no node rendered in the panel carries both `data-detected="true"` and `data-state="done"`** (the sub-project B premise guard, re-asserted at the new layout); the legend names each of the five states. - [ ] **Step 2: run, confirm they fail.** - [ ] **Step 3: implement.** - [ ] **Step 4:** `npm run test:client`, `npm run build`, snapshot diff read. - [ ] **Step 5:** header audit, commit — `feat(lanes): a full-width pipeline panel for the selected lane`. --- ## Task 10 (D10): collapse the console **Files:** Modify `client/src/pages/Workspace.tsx`, `client/src/i18n/locales/{en,zh,vi,ko}/run.json`; modify `client/src/pages/__tests__/Workspace.test.tsx`. **Produces:** `RunSetup` + `RunConsole` + `RunHistory` wrapped in a disclosure that starts collapsed and expands on click, with the console section moved above the card grid so an expanded console sits beside the lane it belongs to. **The subscription must stay mounted while collapsed.** Collapse the visual container with CSS; do NOT conditionally unmount `RunConsole` — unmounting disposes `useRunStream`'s subscription and a live run's envelopes are lost. State in your report which mechanism you used and how you proved the subscription survived. No prop of `RunSetup`, `RunConsole` or `RunHistory` changes. The console still never posts a stage. - [ ] **Step 1: write the failing tests:** the console is collapsed on first render and expands on click; **an envelope delivered through the mocked event bus while the console is collapsed is present in the DOM once it is expanded**; after a full start-and-message cycle no request is made to any `/stage` URL. - [ ] **Step 2: run, confirm they fail.** - [ ] **Step 3: implement.** - [ ] **Step 4:** `npm run test:client`, `npm run build`, snapshot diff read then regenerated. - [ ] **Step 5:** header audit, commit — `feat(lanes): collapse the console without dropping its stream`. --- ## Task 11 (D11): docs **Files:** Modify `docs/LANES.md`, `docs/API.md`, `README.md`, `ARCHITECTURE.md`, `CLAUDE.md`. **Produces:** the new layout described where the old one was; `GET /api/lanes/:id/git` documented with its `available: false` contract and the reason it is not folded into `GET /api/lanes`; the detection TTL documented with `DETECTION_TTL_MS`, its default, and the explicit note that expiry does NOT weaken declared-wins or let inference render `done`. Every path, route and command printed must exist — verify each. - [ ] **Step 1:** write the docs. - [ ] **Step 2:** verify every referenced file, route and command exists (`ls`, `grep`, or run it). - [ ] **Step 3:** full server suite, full client suite, `node scripts/generate-openapi-yaml.js` then `git diff openapi.yaml` empty. - [ ] **Step 4:** header audit, commit — `docs(lanes): document the rebuilt Workspace, git facts and detection expiry`. --- ## Out of scope - Tickets, preview-port links, per-lane credentials, and the `agents`/`creds` buttons from the reference screen — CCAM has no data behind any of them. - Any change to `PipelineMap`'s node-state logic, the destroy guard, or the preflight contract. - Inferring `done` or a gate outcome. Still forbidden, TTL or not. - Re-styling any page other than `/run`.