# PR #13472 capture matrix: tasks panel durations (audit pass 1)

Before: `~/wt/pr-13472-base` @ 8d3b061121. After: `~/wt/pr-13472` @ 40410f7e59.
Change: `apps/web/src/components/chat/ComposerTasksBadge.tsx:204`. The duration `<span>` goes from `w-12` (fixed 48px) to `min-w-12 ... whitespace-nowrap`. Font is `text-3xs` = 0.625rem = 10px (`apps/web/src/index.css:182`), tabular-nums. It sits in `ComposerBanner.Actions` (grid col 3, `flex flex-wrap justify-end`, `apps/web/src/components/chat/ComposerBanner.tsx:257-265`). The label is `ComposerBanner.Content` with `block wrap-anywhere` (`ComposerTasksBadge.tsx:188-200`).
**The collapsed badge (`TaskSummary`, `ComposerTasksBadge.tsx:66-101`) shows no durations.** It only shows the current step (truncate), `n/m`, and segments. It is unchanged, so capture it once per state as a regression control.

## formatDuration outputs (verified by running the source)

`packages/shared/src/orchestrationTiming.ts:34-52`, re-exported at `apps/web/src/session-logic.ts:56`. Ran with `node --experimental-strip-types`:

| ms | string | est. width @10px | before (w-12=48px) |
|---|---|---|---|
| 800 | `800ms` | ~28px | fits |
| 4200 | `4.2s` | ~21px | fits |
| 42000 | `42s` | ~17px | fits |
| 59400 | `59s` | ~17px | fits |
| 59600 | `1m` | ~14px | fits (rounding edge) |
| 125000 | `2m 5s` | ~29px | fits |
| 754000 | `12m 34s` | ~39px | fits |
| 3540000 | `59m` | ~20px | fits |
| 3599000 | `59m 59s` | ~39px | fits (just under an hour) |
| 3600000 | `1h` | ~11px | fits |
| 3661000 | `1h 1m 1s` | ~41px | **borderline, measure** |
| 3725000 | `1h 2m 5s` | ~41px | **borderline, measure** |
| 4980000 | `1h 23m` | ~33px | fits |
| 7384000 | `2h 3m 4s` | ~41px | borderline |
| 9015000 | `2h 30m 15s` | ~53px | **wraps (2 lines)** |
| 45296000 | `12h 34m 56s` | ~60px | **wraps** |
| 86399000 | `23h 59m 59s` | ~60px | **wraps** |

The widths are estimates. The capture agent should record `getBoundingClientRect().height` of `[data-composer-task-duration]` (one line is 14px, from line-height 0.875rem) and `scrollWidth` on both builds. Formats over an hour that drop a unit (`1h`, `1h 23m`) are short and should not wrap even before the fix. The clear repro needs `Xh YYm ZZs`.

## Rows

Steps are `text | status | durationMs`. Status uses the storage form `pending|running|completed`, and the UI maps `running` to inProgress (`session-logic.ts:280`). Themes: light and dark for every row.

| id | viewport | plan steps | expected BEFORE | expected AFTER | crop |
|---|---|---|---|---|---|
| R1-sub-min | 1440, 390 | `Read config` completed 800 · `Scan files` completed 4200 · `Plan edits` completed 42000 · `Write patch` completed 59400 | all single line, right-aligned in 48px | identical | expanded drawer list |
| R2-minutes | 1440, 390 | `Install deps` completed 125000 · `Run tests` completed 754000 · `Typecheck` completed 3540000 · `Lint` completed 59600 | single line | identical | drawer |
| R3-under-hour | 1440, 390 | `Long build` completed 3599000 · `Second step` completed 3599400 | `59m 59s` single line | identical | drawer |
| R4-just-over | 1440, 390 | `Migrate DB` completed 3600000 · `Reindex` completed 3661000 · `Backfill` completed 3725000 · `Verify` completed 4980000 | `1h`/`1h 23m` single line. `1h 1m 1s`/`1h 2m 5s` may wrap (measure) | all single line | drawer |
| R5-multi-hour (core repro) | 1440, 390 | `Full e2e suite` completed 9015000 · `Nightly rebuild` completed 45296000 · `Soak test` completed 86399000 · `Short step` completed 42000 | the multi-hour strings wrap to 2 lines (`2h 30m` / `15s`), the row grows, and the columns misalign | single line. The column grows past 48px and the label column narrows. The `Short step` row's `42s` stays right-aligned | drawer, plus a zoomed crop of the duration column |
| R6-mixed-live | 1440, 390 | `Explore repo` completed 42000 · `Refactor module` completed 754000 · `Run long suite` completed 9015000 · `Fix flakes` running (none) · `Open PR` pending (none) | `2h 30m 15s` wraps. `now` on the running row. Pending is blank | no wrap. `now` and blank unchanged | drawer + collapsed badge (current step `Fix flakes`, `3/5`) |
| R7-long-names | 1440, 390 | `Refactor the authentication middleware so that session refresh no longer races the websocket reconnect handler on flaky networks` completed 9015000 · `Audit every provider adapter for the new cancellation contract and document the per-adapter decision` completed 754000 · `x` completed 45296000 · `Supercalifragilisticexpialidociousunbrokentokenwithoutanyspaces_to_force_wrap_anywhere` pending (none) | the label wraps beside the duration. The multi-hour duration also wraps. Rows are tall | the label wraps (`wrap-anywhere`), the duration stays on one line and top-aligned (`items-start`), and nothing overflows horizontally at 390 | drawer, full row heights. At 390, check for no horizontal scroll in `[data-composer-tasks-scroll]` |
| R8-badge-collapsed | 1440, 390 | R6 steps, drawer collapsed | badge: `Tasks · Fix flakes · 3/5` + segments (segments hidden below a 560px container) | identical (control) | composer badge only |

`now` only renders when `durationMs === undefined && status inProgress && progress.live` (`ComposerTasksBadge.tsx:207-211`; live is computed at `apps/web/src/components/ChatView.tsx:1029`).

## Seeding the plan data (isolated DB)

Data path: SQLite `orchestration_v2_projection_plans.payload_json` → projection `plans[]` → `deriveActivePlanState` (`apps/web/src/session-logic.ts:254-285`, keeps `kind === "todo_list"` with `status !== "superseded"` and maps `{text,status,durationMs}` → `{step,status,durationMs}`) → `selectComposerTasksPlan` (`session-logic.ts:289-317`) → `ChatView.tsx:1019-1042` → `ComposerTasksContent`.

- Schema: `OrchestrationV2PlanStep` `{id, text, status: "pending"|"running"|"completed", durationAnchorAt?, durationMs?: NonNegativeInt}` (`packages/contracts/src/orchestrationV2.ts:1122-1128`). The artifact is `{id, threadId, runId, nodeId, status: "draft"|"active"|"completed"|"superseded", kind: "todo_list", steps, explanation?}` (`orchestrationV2.ts:1150-1171`).
- Domain event: `plan.updated` with `payload: OrchestrationV2PlanArtifact` (`orchestrationV2.ts:1710-1713`). The projection writes `INSERT INTO orchestration_v2_projection_plans (plan_id, thread_id, run_id, node_id, kind, status, payload_json)` (`apps/server/src/orchestration-v2/ProjectionStore.ts:2506-2535`), read back at `ProjectionStore.ts:3385-3404`.
- In real runs, `durationMs` is stamped server-side by `withPlanStepDurations` at ingest (`apps/server/src/orchestration-v2/ProviderEventIngestor.ts:142-201`, called at `:655-679`). Completed steps get `occurredAt - durationAnchorAt`, and `durationMs` is omitted when it is 0. **For fixtures, write `durationMs` directly in `payload_json`. The client never computes it.**
- Optionally mirror a `todo_list` turn item (`orchestrationV2.ts:1379-1384`) so the plan has a timeline anchor. It is not required for the panel (`planItemTime` falls back to run timestamps, `session-logic.ts:319-336`).
- Follow `.agents/skills/test-t3-app/references/sqlite-fixtures.md`. Start each build once on an isolated `--base-dir`, stop it, copy a real thread/run/plan row the app wrote, edit it, write it with `node apps/server/scripts/t3-sqlite-state.ts exec`, and keep project/thread/run/node IDs consistent.

### Visibility rule (important)

`selectComposerTasksPlan` shows a plan only when (a) its run is the active, unsettled run, or (b) the plan has a non-completed step, its run's provider thread driver is `claudeAgent`, and that provider thread owns the latest started run (`session-logic.ts:295-316`). **A plan whose steps are all `completed` on a settled run is hidden.**
- R1–R5 (all completed) therefore need one extra `pending` step, e.g. `Wrap up` pending, plus a run → providerThread with `driver: "claudeAgent"` and `startedAt` set.
- **Live `now` (R6) cannot be produced by seeding.** On startup, `ProviderRuntimeRecoveryService.reconcile("startup")` terminalizes every preparing/starting/running/waiting run (`apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts:79-88, 198-230, 699-730`). Seeds must be written with the server stopped, so a seeded running run is settled before the client loads. `progress.live` is then false, and the running row shows a blank instead of `now`. Either capture R6 with a real short live turn, or capture it seeded and annotate that `now` is blank because the run is not live. `now` is 3 chars and is not affected by this diff in any case.

