feat: Claude Code Monitor — lanes, pipelines and a merged workspace

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.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
@@ -0,0 +1,59 @@
# 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`.** `/lanes` redirects 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:
1. **Runs start through the lane.** The UI always calls `POST /api/lanes/:id/start`, which already exists, sits behind the same-origin guard, and records `run_id` on the lane. `POST /api/run` stays for the CLI and other callers; the UI simply stops using it. Lane `start` gains `mode` so a headless one-shot is still possible.
2. **`POST /api/lanes/ensure`** — `{cwd, title?}` returns the lane owning that path or creates an `adopted` one. Avoids the UI having to catch a 409 and re-read, and keeps the create-then-start pair from racing.
3. **`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 by `cwd`.
4. **A finished run releases its lane.** Today nothing clears `lanes.run_id` when a run ends on its own: the lane reads `running` forever and `message` keeps 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's `run_id` and set its status back to `idle`. 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 (`start` 409s when one exists), so the console shows exactly one stream.
- **A lane created just to try a command** leaves an `adopted` lane behind. Acceptable: `adopted` lanes 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 `/run` is unchanged until the page itself changes.
- `useRunStream`: envelopes merge in order; a `run_status` terminal 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_id` is null and its status is `idle`.
- The console does not move the stage: after a full run through the console, the lane's `stage` is what it was.