Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
12 KiB
Merged Workspace Page 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: Merge the Lanes page and the Run page into one Workspace page at /run — lane strip, pipeline map, and a full Claude console for the selected lane — without losing any Run capability.
Architecture: Design doc: docs/superpowers/specs/2026-07-28-workspace-page-design.md — read it once before Task 1. client/src/pages/Run.tsx (3658 lines) is extracted into a hook and three components in three separate mechanical commits, each leaving the existing tests green with only import changes. Only then is the new page composed. Four small server pieces support it: runs start through the lane, an ensure endpoint, a lane_id on run history, and releasing the lane when a run ends.
Tech Stack: React 18 + TypeScript + Vite + Tailwind, Vitest + Testing Library (client); Node 18+, Express, better-sqlite3, node:test (server).
Global Constraints
- Branch:
feat/workspace-page, cut from the head offeat/stage-detection(or offeat/worktree-lanesif B has not landed). Never work onmaster. - Every
.js/.ts/.tsxfile created or modified MUST start with a file overview comment plus the exact line@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>. Verify withbash .claude/skills/file-headers/scripts/check-headers.sh(must exit 0). - Tasks A1-A3 are pure moves. No behaviour may change, no logic may be "improved" in passing. The existing Run tests must pass with changes to import paths ONLY. If a test needs a real edit to keep passing, stop and report it — that means the move was not pure.
- Extraction and composition never share a commit. A1, A2, A3 are refactors; A5 builds the page.
- The console never writes a lane's stage. No code path from the console may call
POST /:id/stage. Onlyccam stagedeclares; only detection infers. - Schema changes are additive with a per-column probe (
try { SELECT col } catch { ALTER }). - Preserve existing behavior:
POST /api/runand every existing WebSocket message type keep working exactly as they do — the CLI and other callers depend on them.lane_updatestays the only lane WS type. - Server CommonJS; client React + TypeScript. No new npm dependencies. Exact-value assertions; bounded polling, no bare sleeps. i18n strings in all four locales (
en,zh,vi,ko), genuinely translated. - The screens snapshot (
client/src/pages/__tests__/screens.snapshot.test.tsx) covers/run. Read every snapshot diff before accepting it; never regenerate blindly. - The pre-commit hook runs Prettier plus both suites and takes minutes. Let it finish. NEVER
--no-verify. - Baseline at branch point: 857 server tests, 297 client tests (add B's counts if B landed first). Each task leaves
git status --shortempty.
Task 1 (A1): extract useRunStream
Files: Create client/src/hooks/useRunStream.ts; create client/src/hooks/__tests__/useRunStream.test.tsx; modify client/src/pages/Run.tsx.
Produces: useRunStream(runId: string | null) returning {envelopes, status, lastAck}. It owns everything Run.tsx currently does with run_stream / run_status / run_input_ack: the envelope merge (mergeEnvelope, findLastStreamingAssistant, findAssistantByMessageId, mutateAssistantAt), the typewriter (useTypewriterEnvelopes), and the eventBus.subscribe lifecycle. Move those functions; do not rewrite them.
- Step 1: write the hook's tests first — they are new coverage for code that had none: envelopes for the subscribed run id merge in arrival order; an envelope for a different run id is ignored; a streaming assistant envelope updates in place rather than appending; a terminal
run_statusstops further merging; unmounting disposes the subscription (assert the disposer returned byeventBus.subscribewas called). - Step 2: run, confirm they fail —
cd client && npx vitest run src/hooks/__tests__/useRunStream.test.tsx. - Step 3: move the code. Cut the named functions out of
Run.tsxinto the hook, export them if the tests need them, and haveRun.tsxcall the hook. Delete the now-dead copies. Change nothing else. - Step 4: verify the move was pure —
npm run test:client(all pre-existing Run tests green, no test bodies edited),npm run build, andgit diff client/src/pages/__tests__/must show no snapshot change. - Step 5: header audit, commit —
refactor(run): extract useRunStream from the Run page.
Task 2 (A2): extract RunConsole
Files: Create client/src/components/run/RunConsole.tsx; modify client/src/pages/Run.tsx.
Produces: <RunConsole runId prompt onPromptChange onSubmit onStop slashCommands busy /> rendering the envelope stream, the prompt editor with its slash autocomplete (PromptEditor, detectAutocomplete, scoreSlashMatch, subsequenceMatch, commandSourceLabel, commandSourceTone), and the token meter (TokenMeter, computeTokens, formatNum). It consumes useRunStream from A1. Props only — no direct API calls, so the same console can serve the Run page and the Workspace page.
- Step 1: write the failing test —
client/src/components/run/__tests__/RunConsole.test.tsx: given envelopes from a mockeduseRunStream, the assistant text renders; typing/coshows the matching slash command and picking one fills the prompt; the token meter shows the computed totals;onSubmitfires with the prompt text;onStopfires from the stop control. - Step 2: run, confirm it fails.
- Step 3: move the code. Pure move plus the props boundary. Do not redesign the editor.
- Step 4:
npm run test:client(pre-existing Run tests green with only import changes),npm run build, snapshot unchanged. - Step 5: header audit, commit —
refactor(run): extract RunConsole from the Run page.
Task 3 (A3): extract RunSetup and RunHistory, leave Run.tsx thin
Files: Create client/src/components/run/RunSetup.tsx, client/src/components/run/RunHistory.tsx; modify client/src/pages/Run.tsx.
Produces: <RunSetup> owning mode / model / permission-mode / effort / cwd / resume-session pickers, the binary-status check and LimitationsBanner; <RunHistory> owning past runs, live runs and attach. After this task Run.tsx holds only page-level state and composition — report its final line count in your report.
- Step 1: write the failing tests for both components:
RunSetupreports each selection through its callbacks and surfaces a missing-binary state;RunHistorylists history, marks a live run, and fires attach with the right run id. - Step 2: run, confirm they fail.
- Step 3: move the code.
- Step 4:
npm run test:client,npm run build, snapshot unchanged. - Step 5: header audit, commit —
refactor(run): extract RunSetup and RunHistory, thin the Run page.
Task 4 (A4): the server glue
Files: Modify server/db.js, server/lib/lanes.js, server/routes/lanes.js, server/lib/run-spawner.js, server/__tests__/lane-lifecycle.test.js; docs as listed in the constraints.
Produces:
-
POST /api/lanes/ensure{cwd, title?}→{lane, created: boolean}. Returns the lane that ownscwd(exact match or the longest path-boundary parent, reusingresolveLaneByCwd), else creates anadoptedlane. Behind the same-origin guard. Concurrent calls for the same path must yield ONE lane — rely on thecwdUNIQUE constraint and treat the constraint violation as "someone else created it, re-read and return it". -
modeaccepted by the lanestartaction and passed through tospawnRun, so a headless one-shot is reachable through a lane. -
dashboard_runs.lane_id, one additive probe, written when a run starts through a lane;GET /api/run/historyaccepts an optionallaneIdfilter. -
A finished run releases its lane. When the run-spawner observes a child's real exit, clear
run_idand setstatus: "idle"on the lane holding thatrun_id, and broadcastlane_update. Do this without creating a require cycle (run-spawnermust not import a route module — read howbroadcastLaneis exported and pick the clean direction, or invert it with a callback registered at boot). State in your report which direction you chose and why. -
Step 1: write the failing tests:
ensurereturns the existing lane for an exact path, for a nested path, and creates one otherwise; two concurrentensurecalls for the same path create exactly one lane; a lane-started run recordslane_idindashboard_runsandGET /api/run/history?laneId=filters by it; when a run ends on its own, the lane'srun_idbecomes null and its status returns toidle; a cross-originPOST /api/lanes/ensureis refused. -
Step 2: run, confirm they fail.
-
Step 3: implement.
-
Step 4:
npm run test:server; regenerateopenapi.yamland confirmgit diff openapi.yamlis empty. -
Step 5: header audit, commit —
feat(lanes): ensure endpoint, run history per lane, release the lane when a run ends.
Task 5 (A5): compose the Workspace page
Files: Create client/src/pages/Workspace.tsx; modify client/src/App.tsx, client/src/components/Sidebar.tsx, client/src/lib/api.ts, client/src/i18n/locales/*/lanes.json; modify client/src/pages/__tests__/screens.snapshot.test.tsx.
Produces: the merged page at /run; /lanes redirects to it (<Navigate to="/run" replace />); one sidebar entry. Layout top to bottom: lane strip (horizontal scroll, counters, Add) → PipelineMap for the selected lane → RunSetup → RunConsole → RunHistory filtered to the lane. Selecting a lane switches pipeline, console and history together. Starting a run goes through POST /api/lanes/:id/start; choosing a cwd that no lane owns calls POST /api/lanes/ensure first. api.lanes gains ensure.
- Step 1: write the failing tests —
client/src/pages/__tests__/Workspace.test.tsx: the strip lists lanes and the counters match; selecting a lane switches the pipeline and the console's run id; starting a run posts to the LANE start endpoint (assert the URL, not just that something was called); picking an unowned cwd callsensurebeforestart; after a full start-and-message cycle the lane's stage is never posted to (assert no call to any/stageURL). - Step 2: run, confirm they fail.
- Step 3: implement. Keep
Run.tsx's remaining shell only if something still needs it; if the Workspace page fully replaces it, delete it and say so. - Step 4:
npm run test:client,npm run build. The screens snapshot WILL change here — read the diff, confirm it is only the merged layout, then regenerate. - Step 5: header audit, commit —
feat(lanes): merge the Lanes and Run pages into one Workspace.
Task 6 (A6): docs and the seams
Files: Modify docs/LANES.md, docs/API.md, server/README.md, ARCHITECTURE.md, README.md, server/openapi-extra/lanes.js (+ regenerate openapi.yaml), CLAUDE.md.
Produces: documentation of the merged page and the new seams: that /lanes redirects to /run; that the UI starts runs through the lane while POST /api/run remains for the CLI; the ensure endpoint and when the UI calls it; dashboard_runs.lane_id; that a finished run releases its lane. CLAUDE.md gains the rule: the console never writes a lane's stage — declared comes from ccam stage, inferred from detection. Every path and command you print 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:
npm run test:server,npm run test:client,node scripts/generate-openapi-yaml.jsthengit diff openapi.yamlempty. - Step 4: header audit, commit —
docs(lanes): document the merged Workspace page and its seams.
Out of scope
- Any redesign of the prompt editor, the envelope renderer, or the token meter. A1-A3 move them unchanged.
- Multiple concurrent runs per lane.
startalready 409s when one is live. - Per-lane dependency bootstrap for a fresh worktree (still sub-project D if ever wanted).
- Stage inference — that is sub-project B, and the console must not do it either way.