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.
5.6 KiB
Merged Workspace page — design
Status: approved 2026-07-28. Sub-project A of three (C = worktree lanes, shipped; B = stage detection, planned). Built last because it consumes both.
Problem
Lanes and Run are two pages that describe the same activity. Run spawns a claude process in a directory and streams its output; Lanes shows what a lane is doing and where it is in its pipeline. A user watching an agent work has to hold both in their head, and the lane card cannot even send a prompt — start opens a promptless conversation run and message has no input field.
Goal
One page. A lane strip across the top, the selected lane's pipeline beneath it, and that lane's Claude console below — with every capability the Run page has today.
Decisions already taken
- The merged page lives at
/run./lanesredirects there. One sidebar entry. - Every run belongs to a lane. Choosing a working directory that no lane owns creates one (
kind='adopted') rather than running loose. A lane is, after all, just a working directory the dashboard is watching. - Everything from Run survives: slash-command autocomplete in the prompt editor, model / permission-mode / effort selectors, the token meter with cost, run history, and attach-to-a-live-run.
- Layout: lane strip (horizontal, scrollable, with the counters and Add) → pipeline map of the selected lane → console. Selecting a lane switches both the pipeline and the console.
Architecture
client/src/pages/Run.tsx is 3658 lines holding an envelope model, a merge/typewriter engine, a slash-autocomplete prompt editor, a token meter, cwd suggestions, run history and the page shell. It is extracted into pieces that the new page composes:
| Unit | Responsibility |
|---|---|
client/src/hooks/useRunStream.ts |
envelope state for one run id: subscribe run_stream / run_status / run_input_ack, merge envelopes, typewriter |
client/src/components/run/RunConsole.tsx |
render the envelope stream, the prompt editor with slash autocomplete, the token meter, stop/clear |
client/src/components/run/RunSetup.tsx |
mode / model / permission / effort / cwd / resume pickers, binary status, the limitations banner |
client/src/components/run/RunHistory.tsx |
past runs, live runs, attach |
client/src/pages/Workspace.tsx |
lane strip + PipelineMap + the three above |
The extraction is mechanical and must not change behaviour. Each unit moves in its own commit with the existing Run tests passing untouched except for import paths. Only once Run.tsx is a thin composition does the new page get built. Extraction and composition never share a commit — that is the difference between a reviewable refactor and an unreviewable rewrite.
Server glue
Four small pieces, each independently useful:
- Runs start through the lane. The UI always calls
POST /api/lanes/:id/start, which already exists, sits behind the same-origin guard, and recordsrun_idon the lane.POST /api/runstays for the CLI and other callers; the UI simply stops using it. Lanestartgainsmodeso a headless one-shot is still possible. POST /api/lanes/ensure—{cwd, title?}returns the lane owning that path or creates anadoptedone. Avoids the UI having to catch a 409 and re-read, and keeps the create-then-start pair from racing.dashboard_runs.lane_id— one additive column, set when a run is started through a lane, so history can be filtered per lane instead of guessed at bycwd.- A finished run releases its lane. Today nothing clears
lanes.run_idwhen a run ends on its own: the lane readsrunningforever andmessagekeeps targeting a dead run. The run-spawner already knows the moment of exit (actualExitedAt, added on the worktree branch); on that event, clear the owning lane'srun_idand set its status back toidle. This is a bug the merge exposes rather than causes.
What the console must not do
It never touches the lane's stage. Typing /code-review in the UI does not move the lane to review; only ccam stage declares, and only detection (sub-project B) infers. The console is a window onto a process, not a driver of the pipeline. Keeping that boundary is what stops the pipeline from becoming a lie.
Risks and how they are contained
- The extraction is the whole risk. 3658 lines, one of them the typewriter engine, with a screens snapshot over the page. Containment: one unit per commit, tests untouched but for imports, snapshot diffs read rather than regenerated, and the composition deferred until the last extraction is green.
- Two consoles for one lane. Only one run is live per lane (
start409s when one exists), so the console shows exactly one stream. - A lane created just to try a command leaves an
adoptedlane behind. Acceptable:adoptedlanes are never destroyable, forgetting one is a click, and the alternative — runs that belong to nothing — is what this design set out to remove.
Testing
- Each extraction: the existing Run tests pass with only import changes, and the screens snapshot for
/runis unchanged until the page itself changes. useRunStream: envelopes merge in order; arun_statusterminal event stops the stream; the subscription is disposed on unmount.POST /api/lanes/ensure: returns the existing lane for a path already owned, for a path nested inside one, and creates exactly one lane under concurrent calls.- Run-exit releases the lane: after a run ends by itself, the lane's
run_idis null and its status isidle. - The console does not move the stage: after a full run through the console, the lane's
stageis what it was.