# Agent Page + /plans List Rework Implementation Plan

> **For agentic workers:** executed as ONE sequential codex run. Waves give ordering. Spec (MUST
> read first): `docs/specs/2026-07-18-agent-page-and-plans-list-spec.md`. Design reference (MUST
> open): `scratchpad/agent-page-mockup-v3.html`. Same hard rules + idiom as the landed
> run-page-v2 work (`docs/specs/2026-07-18-run-page-v2-spec.md`).

**Goal:** build `/plans/[runId]/agents/[agentId]` from the approved mockup and rework `/plans`
into a two-pane viewport-fit list with honest, enriched rows.

**Architecture:** presentation in `@overdeck/deck-ui` (tokens + Tailwind, colocated tests);
data wiring in `apps/web/src/components/plans/` apps; routes `prerender = false`.

**Branch:** `plan/agent-page-list` off `main`. Commit per task, terse imperative, no co-author.
NEVER push — landing runs separately via `ship.sh land` (which now also deploys locally).

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | T1 format helper, T2 AgentStatusBar+IdleTimer, T3 AgentFeed+TurnCard, T4 AgentMetaSidebar+AgentComposer, T5 PlanRunListRow | one component family each (+ sequential index.ts appends) | ✅ disjoint |
| 2 | T6 agent page assembly, T7 /plans rework | [runId]/agents/[agentId].astro + AgentApp.tsx vs PlansContent.tsx | ✅ disjoint |
| 3 | T8 verification | verify script only | single |

---

### T1: `formatRelativeTime`

**Files:** Modify `packages/deck-ui/src/format.ts` + `format.test.ts` (create if absent); export from index.

**Contract:** `export function formatRelativeTime(epochMs: number, nowMs?: number): string` —
"just now" (<60s), "Nm ago", "Nh ago", "Nd ago"; future timestamps → "in Nm" etc. `nowMs`
injectable for tests.

**Acceptance:** unit test covers all buckets + future + injection. `pnpm --filter @overdeck/deck-ui test -- format` PASS.

- [ ] Test → implement → green → commit.

### T2: `AgentStatusBar` + `IdleTimer`

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

**Contract:**
```ts
export interface AgentIdentity { agentId: string; seat: string; taskId: string; model: string
  live: boolean; stateLabel: string }   // stateLabel = plain-language line from mockup
export function AgentStatusBar(props: { agent: AgentIdentity
  onRestart(): void                      // wired via steer restart — REAL
  toastHandle: UndoToastHandle           // Restart = 5s pending action w/ Undo (same as abort)
  killPauseAvailable: false }): JSX.Element   // literal false until B3; disabled + tooltip "needs harness B3"
export function IdleTimer(props: { idle: { lastProgressAt: number; idleTimeoutMs: number
  alive: boolean } | null }): JSX.Element     // null → "idle signal needs A10" gap; NEVER a fake clock
```
**Behavior:** controls render ONLY when `live`; Restart routes through
`toastHandle.toastUndo("Restarting <agentId> — nothing sent yet.", {seconds:5, onCommit: onRestart, ...})`.
IdleTimer with data: 1s tick, tiers Working / No progress (≥idleTimeoutMs) / Watchdog recovering
(≥2×); `alive:false` → "Ended".

**Acceptance:** fake-timer tests: restart undo/commit paths; idle tiers at threshold boundaries; null gap state.

- [ ] Test → implement → green → commit.

### T3: `AgentFeed` + `TurnCard`

**Files:** Create both + tests + fixture (fixture built from a REAL forensics segments snapshot).

**Contract:**
```ts
export interface AgentTurn { at: number; durMs: number | null   // null = in progress
  phase: string                                                  // cat: llm-implement/gate0/…
  summary: string; detail?: string; journalSeq?: number }
export function AgentFeed(props: { turns: AgentTurn[]; live: boolean
  rawLogAvailable: false }): JSX.Element   // literal false until B2 → toggle renders gap state
```
**Behavior:** chronological TurnCards (phase chip, when + duration via formatDurationMs/
formatRelativeTime, summary); live mode: newest visible, in-progress turn pulses; historical:
"ended" banner instead. Raw-log toggle present but showing "live log needs harness B2" panel.

**Acceptance:** tests: ordering, in-progress rendering, historical banner, gap-state toggle.

- [ ] Test → implement → green → commit.

### T4: `AgentMetaSidebar` + `AgentComposer`

**Files:** Create both + tests.

**Contract:**
```ts
export function AgentMetaSidebar(props: { seat: string; model: string; attempt?: number
  branch?: string; taskId: string; taskHref: string
  account: null }): JSX.Element            // null → "not recorded (A9)" + tooltip; cost line ABSENT (A8)
export function AgentComposer(props: { onSend(text: string): Promise<void> }): JSX.Element
```
**Behavior:** composer = textarea + send, disabled while in flight, error surfaced inline (no
toast-swallow); mounted ONLY for live agents (caller enforces; component itself stateless).
Fills width under feed — no bottom gap.

**Acceptance:** tests: send lifecycle (pending/disabled/error), account gap render.

- [ ] Test → implement → green → commit.

### T5: `PlanRunListRow`

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

**Contract:**
```ts
export function PlanRunListRow(props: { title: string; status: string; seq?: number
  tasksCompleted: number; tasksTotal: number; updatedAtMs: number; activeAgents: number
  selected: boolean; onSelect(): void; href: string }): JSX.Element
```
**Behavior:** one compact scannable row: status badge (colored by status), `run #<seq>` (absent →
omit, never invent), `N/M tasks`, relative last-activity (absolute on hover via DeckTooltip),
active-agent count shown only when > 0 (NO `/max` — not in data). Selected highlight; Open
affordance navigates `href`, row click selects.

**Acceptance:** tests: chips render, seq-absent omission, selection callback vs navigation.

- [ ] Test → implement → green → commit.

### T6: agent page assembly

**Files:** Create `apps/web/src/pages/plans/[runId]/agents/[agentId].astro`
(`export const prerender = false`), `apps/web/src/components/plans/AgentApp.tsx`;
Modify `apps/web/src/components/plans/PlanRunApp.tsx` (SegmentDetailDrawer open-agent +
attention-row agent actions → real hrefs).

**Contract:** `AgentApp({ runId, agentId })`: derive the agent's turns from forensics segments
(`agentId` match; sort by t0) + timeline detail; identity from plans-panel task node (seat, model
from seat binding fields available in the node — omit model if not present, never invent);
`live` = owning task `status === 'running'`; Restart → `postCollectorAction('steer', {args:
{runId, taskId, restart: 'true'}, requestedBy: 'overdeck-web'})` (match the verb's existing arg
shape — read `collector/src/actions.ts` first); composer → same verb with `text`. SSE refresh.
Back link to `/plans/<runId>`. Unknown agentId → honest empty state listing the run's known
agentIds (from segments), NOT a 500.

**Acceptance:** `pnpm --filter web build` + typecheck green; live probe: real agentId renders
feed from real segments; unknown agentId shows the empty state.

- [ ] Implement → gates green → commit.

### T7: /plans list rework

**Files:** Modify `apps/web/src/components/plans/PlansContent.tsx`; Modify
`packages/deck-ui/src/RunLanes.tsx` (heading string + doc-comment ONLY); grep-kill remaining
"nine lives" strings repo-wide (tests included).

**Contract:** two-pane grid per spec (verify bounded-height ancestor FIRST; else pane claims
`h-[calc(100vh-<offset>)]`). Left pane: `PlanRunListRow` list (rows from plans panel — status,
seq, updatedAt→ms, active = count of running tasks). Right pane: existing forensics block
unchanged except heading "Runs". Selection logic (`activeRunId`) preserved.

**Acceptance:** build green; live probe: page fits viewport (no body scroll), left pane scrolls
with 60+ runs, rows show chips, zero "nine lives"/"Nine lives" matches repo-wide.

- [ ] Implement → gates green → commit.

### T8: verification

**Files:** Create `scratchpad/verify-agent-page.mjs` (pattern: `scratchpad/verify-run-page-v2.mjs`).

**Steps:**
- [ ] `pnpm --filter @overdeck/deck-ui test` ALL green; `pnpm --filter web build` + typecheck green, zero new warnings.
- [ ] Playwright (live app if reachable, else built output / fixtures — state which): composer
  no-bottom-gap (composer bottom within 24px of pane bottom), live↔historical switch, disabled
  Pause/Kill tooltips, /plans viewport-fit + left-pane scroll assertion, zero console errors,
  light+dark screenshots to `scratchpad/shots/`.
- [ ] Grep built output for mockup demo literals — zero hits.
- [ ] Commit fixes; final commit.

## Self-review notes (done at authoring)

- Spec table ↔ tasks 1:1 (six component families T1–T5, assembly T6–T7, verify T8).
- Cross-refs: `UndoToastHandle`/`DeckTooltip`/`DataCoveragePanel`/`formatDurationMs` exist on main
  (landed run-page-v2); `steer` verb arg shape deliberately left to T6 to read from
  `collector/src/actions.ts` (source of truth) rather than pinned here possibly wrong.
- No same-wave file overlap except sequential index.ts appends (single sequential executor).
