# Run Page v2 (Version B) Implementation Plan

> **For agentic workers:** executed as ONE sequential codex run (not the wave-parallel harness).
> Waves below give ordering; same-wave tasks are independent but run in order. Steps use
> checkbox (`- [ ]`) syntax. Spec (MUST read first): `docs/specs/2026-07-18-run-page-v2-spec.md`.
> Design reference (MUST open + follow): `scratchpad/run-page-reinvented.html`.

**Goal:** componentize the signed-off Version-B run "control room" as `/plans/[runId]`, wired to
real collector/harness data, honest gap states for everything blocked on the harness spec.

**Architecture:** presentation = new `@overdeck/deck-ui` components (tokens + Tailwind, colocated
tests, fixtures). Data = `apps/web/src/components/plans/PlanRunApp.tsx` (queries + SSE + action
posts only). Route = `apps/web/src/pages/plans/[runId].astro`, `prerender = false`.

**Tech stack:** React 18, Astro (static + node adapter), Tailwind, vitest, Playwright (verify).

**Branch:** work on `plan/run-page-v2` off `main`. Commit per task, terse imperative message,
no co-author line. NEVER push.

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | T1 tokens+tooltip, T2 UndoToast, T3 data widening | tokens.css+DeckTooltip.tsx, UndoToast.tsx, harness.ts+panel-data.ts | ✅ disjoint (index.ts appends run sequentially) |
| 2 | T4 Attention, T5 DistanceToDone, T6 Capacity+RateLimitDialog, T7 SwimlaneTrace, T8 Settings, T9 DecisionDialog, T10 CommandBar+Coverage | one component family each | ✅ disjoint (sequential run anyway) |
| 3 | T11 page assembly | PlanRunApp.tsx, [runId].astro, PlansContent.tsx | single |
| 4 | T12 verification | verify script only | single |

All deck-ui component tasks ALSO append their exports to `packages/deck-ui/src/index.ts` —
acceptable overlap because execution is sequential.

---

### T1: tokens + `DeckTooltip`

**Files:** Modify `packages/deck-ui/src/tokens.css`; Create `packages/deck-ui/src/DeckTooltip.tsx` + `DeckTooltip.test.tsx`.

**Contract:** add tokens needed by v2 (condition/ratelimit color, severity stripes, playhead) as
`--mod-color-*` custom properties — mine the mockup's `:root` for WHICH semantics are needed, map
values into the existing token scheme (both themes). `DeckTooltip`: provider-less pointer-follow
tooltip; API = `useDeckTooltip()` returning props spreadable on targets
(`{'data-tipb': string, 'data-tips'?: string}` pattern from mockup) + `<DeckTooltipLayer/>`
mounted once per page. Keeps within viewport (flip at edges, pad 14px).

**Behavior:** bold first line, muted second; shows on pointerover of any `[data-tipb]`; never
blocks pointer events; hidden on pointerout.

**Acceptance:** `pnpm --filter @overdeck/deck-ui test -- DeckTooltip` PASS — renders lines,
repositions near right edge.

- [ ] Test → implement → test green → commit.

### T2: `UndoToast`

**Files:** Create `packages/deck-ui/src/UndoToast.tsx` + `.test.tsx`.

**Contract:**
```ts
export interface UndoToastHandle {
  toast(msg: string): void                                   // plain, 3.4s
  toastUndo(msg: string, opts: { seconds?: number           // default 5, MIN 5
    onUndo(): void; onCommit(): void }): void
}
export function UndoToast(props: { handleRef: Ref<UndoToastHandle> }): JSX.Element
```
**Behavior:** actionable mode renders message + `Undo (N)` button, tabular-nums, counts down 1/s,
persists full `seconds`, focus moves to button; Undo → dismiss + `onUndo`; expiry → dismiss +
`onCommit`; a new toast cancels timers of the previous (pending action commits are NOT lost —
calling `toastUndo` while another pending exists commits the previous immediately first). Mockup
behavior reference: `toastUndo` in `scratchpad/run-page-reinvented.html`.

**Acceptance:** test with fake timers: countdown ticks, undo path, commit path, replace-commits-previous.

- [ ] Test → implement → green → commit.

### T3: widen plans-panel task data

**Files:** Modify `collector/src/adapters/harness.ts` (`buildPlansPanel`), `apps/web/src/lib/panel-data.ts`.

**Contract:** `HarnessWaveTask` becomes `{ id: string; status: string; state?: string;
seat?: string; deps?: string[]; attempt?: number; branch?: string }` — forward those fields from
`dag.nodes` verbatim (no renames); forward run-level `seq: number`. Existing consumers unaffected
(additive).

**Acceptance:** `pnpm --filter collector test` (or typecheck if no test) + `pnpm --filter web build` green;
collector emits the fields (assert in existing harness adapter test if present, else add one).

- [ ] Implement → gates green → commit.

### T4: `AttentionPanel` + `AttentionRow`

**Files:** Create `packages/deck-ui/src/AttentionPanel.tsx`, `AttentionRow.tsx`, tests, fixture.

**Contract:**
```ts
export interface AttentionItem {
  id: string; severity: 'critical' | 'action' | 'info'
  title: string; sub?: string; detail: ReactNode
  actions: Array<{ label: string; kind?: 'primary' | 'ghost'; onClick(): void }>
}
export function AttentionPanel(props: { items: AttentionItem[] }): JSX.Element
```
**Behavior:** grid on the LIST (`grid-template-columns: 4px minmax(240px,.9fr) minmax(300px,1.4fr) 320px`),
rows `display:grid; grid-template-columns:subgrid; grid-column:1/-1` — shared tracks, 0px edge
spread REQUIRED. Severity stripe color per severity. Empty state: "Nothing needs you — run is
executing. You'll see a row here the moment a decision, failure, or stall needs a human."

**Acceptance:** component test renders 3 rows + empty state; visual spread asserted in T12.

- [ ] Test → implement → green → commit.

### T5: `DistanceToDone`

**Files:** Create `packages/deck-ui/src/DistanceToDone.tsx` + test + fixture.

**Contract:**
```ts
export interface TaskStateCell { id: string; name: string
  state: 'landed' | 'active' | 'blocked' | 'queued' | 'failed' }
export function DistanceToDone(props: {
  landed: number; total: number; cells: TaskStateCell[]
  etaMs: number | null            // null → render "ETA — needs ≥1 measured task"
  fixLoop: { percent: number } | null   // null → labeled gap state, NEVER a number
  truthNote: string }): JSX.Element
```
**Behavior:** big `landed/total tasks truly landed` numeral, per-task cells colored by state, truth
note verbatim slot ("'Landed' is verified, never claimed…" — copy from mockup), ETA line, fix-loop
meter or its gap state.

**Acceptance:** test renders counts, null-ETA and null-fixLoop gap states (no numbers).

- [ ] Test → implement → green → commit.

### T6: `WrapperCapacity` + `RateLimitDialog`

**Files:** Create `packages/deck-ui/src/WrapperCapacity.tsx`, `RateLimitDialog.tsx`, tests, fixture.

**Contract:**
```ts
export interface WrapperRateLimit { wrapper: string; active: boolean; account?: string
  resumeAtMs?: number; wait?: number; max?: number; parkedTasks?: string[]
  affectedSeats?: string[] }                     // mirrors harness spec B7
export function WrapperCapacity(props: { wrappers: string[]; ratelimits: WrapperRateLimit[]
  dataAvailable: boolean                          // false until B7 lands
  onOpen(wrapper: string): void }): JSX.Element
export function RateLimitDialog(props: { limit: WrapperRateLimit | null; wrapper: string
  accounts: Array<{ slug: string; health: 'ready' | 'limited' | 'cooling'; note?: string }> | null
  open: boolean; onClose(): void
  onResolve(choice: { kind: 'wait' | 'retry' | 'switch'; account?: string }): void }): JSX.Element
```
**Behavior:** badges `cursor ✓ · codex ⏳12:34` (ticking); `dataAvailable:false` → badges plain
wrapper names + tooltip "no limit data yet (B7)", NO countdowns. Dialog: mockup's what-happened /
scope line / 3 radio options / `<wrapper> account chain` list / recommendation; healthy wrapper →
"No limit — account chain healthy" read-only.

**Acceptance:** tests: healthy, limited-with-countdown, dataAvailable:false (asserts NO countdown
rendered), resolve callbacks fire with the right payload.

- [ ] Test → implement → green → commit.

### T7: `SwimlaneTrace` + `TraceSegment` + `SegmentDetailDrawer`

**Files:** Create `packages/deck-ui/src/SwimlaneTrace.tsx`, `TraceSegment.tsx`,
`SegmentDetailDrawer.tsx`, tests, fixture (build from a REAL `forensics:<runId>` snapshot).

**Contract:**
```ts
export interface TraceLane { taskId: string; name: string; wave: number
  segments: Array<{ t0: number; durMs: number; cat: string; agentId?: string
    running?: boolean; note?: string; journalSeq?: number }> }
export interface ConditionBand { wrapper: string; fromMs: number; toMs: number
  laneIds: string[]; label: string }              // band ONLY over laneIds
export function SwimlaneTrace(props: { lanes: TraceLane[]; nowMs: number | null
  bands: ConditionBand[]; zoom: 'fit' | 'hour' | 'live'
  onZoom(z: 'fit' | 'hour' | 'live'): void
  onSegmentClick(laneId: string, segIndex: number): void }): JSX.Element
```
**Behavior:** absolute-time positioning, NOW playhead (nowMs null → hidden), wave separators,
bands rendered ONLY over their `laneIds` (behind segments, hoverable label), tooltips via
`DeckTooltip` (cat label, agent, duration, start offset). Drawer: when/who/note/evidence
(`journal seq N · <cat>.* events`) + open-agent link.

**Acceptance:** tests: segment geometry (left/width % from t0/durMs), band constrained to given
lanes, playhead position, drawer fields.

- [ ] Test → implement → green → commit.

### T8: `SettingsTable` + `SettingRow` + `LayerLadderEditor`

**Files:** Create the three components + tests + fixture.

**Contract:**
```ts
export interface SettingLayer { layer: 'engine' | 'home' | 'repo' | 'plan' | 'run'
  value: string | null; editable: boolean }       // editable per mutation class
export interface SettingItem { key: string; label: string; value: string
  decidedBy: SettingLayer['layer']; mutation: 'mid-run' | 'new-runs' | 'immutable'
  layers: SettingLayer[] }
export function SettingsTable(props: { items: SettingItem[] | null    // null → B5 gap state
  onSave(key: string, layer: SettingLayer['layer'], value: string): void }): JSX.Element
```
**Behavior:** aligned table Setting/Value/Decided by/Can I change it? (source dots, NO pills); row
click expands INLINE ladder showing every layer, inputs where `editable`, save annotates
"run override applies now" vs "applies to new runs". `items:null` → honest "config surface needs
B5" panel.

**Acceptance:** tests: table renders, ladder expands with all 5 layers, immutable layer has no
input, null gap state.

- [ ] Test → implement → green → commit.

### T9: `DecisionDialog` (with pending-abort undo)

**Files:** Create `packages/deck-ui/src/DecisionDialog.tsx` + test.

**Contract:**
```ts
export interface DecisionDetail { taskId: string; needs: string; why: string
  blastRadius?: string; options: string[] }
export function DecisionDialog(props: { detail: DecisionDetail | null; open: boolean
  onClose(): void
  onDecide(option: string): void        // caller posts to /decisions
  toastHandle: UndoToastHandle }): JSX.Element
```
**Behavior:** full detail from `/decisions` (missing fields → labeled "not provided by harness
(A7)"). `abort` choice NEVER calls `onDecide` immediately: close dialog, hide source row
(caller-provided via onClose semantics is NOT enough — dialog calls
`toastHandle.toastUndo("Aborting <taskId> — nothing sent yet.", {seconds:5, onUndo, onCommit:
() => onDecide('abort')})`). Every other option decides immediately. Dialog hugs content (no
bottom gap).

**Acceptance:** fake-timer test: abort → no onDecide before 5s; undo → onDecide never fires;
expiry → onDecide('abort') exactly once; proceed → immediate.

- [ ] Test → implement → green → commit.

### T10: `RunCommandBar` + `DataCoveragePanel`

**Files:** Create both + tests.

**Contract:** `RunCommandBar(props: { title: string; status: string; state: string; seq?: number;
updatedAt: string; capacity: ReactNode; onPause?(): void; onKill?(): void;
coverage: ReactNode })` — sticky bar per mockup; missing handler → button disabled + tooltip
"needs harness B3". `DataCoveragePanel(props: { gaps: Array<{ id: string; label: string;
specRef: string }> })` — demoted popover listing A8/A9/A10/B7 gap lines.

**Acceptance:** tests: disabled-state tooltips, coverage list renders specRefs.

- [ ] Test → implement → green → commit.

### T11: page assembly

**Files:** Create `apps/web/src/pages/plans/[runId].astro` (`export const prerender = false`),
`apps/web/src/components/plans/PlanRunApp.tsx`; Modify `apps/web/src/components/plans/PlansContent.tsx`
(row → link `/plans/<runId>` affordance only, keep selection behavior).

**Contract:** `PlanRunApp({ runId })` wires: plans panel run → CommandBar + DistanceToDone cells
(landed = task `status === 'succeeded'`… map statuses verbatim, landed ONLY from journal-verified
status, never text claims); forensics segments → SwimlaneTrace lanes (group by taskId; `__run__`
lane for idle-restart), nowMs = live run ? Date.now() : null; measured durations → etaMs (median
completed-task duration × remaining, null when no completed tasks); decisions GET → AttentionPanel
items + DecisionDialog, POST via collector action (add `decision` verb to `collector/src/actions.ts`
allowlist if absent, same pattern as `steer`); pendingDecisions/degradedReason → attention items;
ratelimits: `dataAvailable:false`; SettingsTable `items:null`; DataCoveragePanel gaps A8/A9/A10/B7.
SSE refresh via `useSseStream`. Back link to `/plans`.

**Behavior:** NO fabricated values anywhere; every blocked surface shows its labeled gap state.

**Acceptance:** `pnpm --filter web build` green; typecheck green; page renders against live
collector on :31337 for a real runId.

- [ ] Implement → gates green → commit.

### T12: verification

**Files:** Create `scratchpad/verify-run-page-v2.mjs` (pattern: `scratchpad/verify-multiwrapper.mjs`
+ `scratchpad/verify-undo-abort.mjs`).

**Steps:**
- [ ] `pnpm --filter @overdeck/deck-ui test` — ALL green.
- [ ] `pnpm --filter web build` + typecheck — green, zero new warnings (no-ignored-signals).
- [ ] Playwright against the running app (`http://localhost:31337/plans/<real-runId>` — pick a
  runId from the collector's plans panel): AttentionRow 0px edge spread; abort-undo countdown/
  restore/commit; band-vs-lane containment (skip if no band data live — assert via component test
  fixture instead); zero console errors; light+dark screenshots to `scratchpad/shots/`.
- [ ] Grep built page output for mockup demo literals (`8c09ff`, `19:29–19:42`, `12:34`) — zero hits.
- [ ] Commit any fixes; final commit.

## Self-review notes (done at authoring)

- Spec coverage: every spec inventory row has a task (T1–T10), assembly T11, acceptance T12.
- Contracts self-consistent: `UndoToastHandle` (T2) consumed by T9; `WrapperRateLimit` mirrors
  harness spec B7; `TraceLane` derives from `ForensicsSegment` fields (t0/durMs/cat/taskId/agentId).
- No task writes implementation bodies; all dispatched tasks are contract-level.
