# PR #22788 capture matrix, round 2 (proof auditor pass 1)

Auditor: Claude Opus 5.5 (Claude Code), read-only, 2026-10-22.
BEFORE = `/tmp/ev-22788/base` @ `ed3f965e07` (origin/main). AFTER = `~/wt/pr-22788` @ local merge `c4f9d0e114` (PR head `9ec682fce8`).
I read: `git diff ed3f965e07 -- apps/web`, main's `useTimelineFollow.ts`, `MessagesTimeline.tsx` and `.logic.ts`, LegendList 3.3.5 `react.mjs` (`checkAtBottom`, `doMaintainScrollAtEnd`), and `packages/provider-acp/src/server/{runtimeModel,AcpSessionRuntime}.ts`.

## 0. Corrections to brief.md (the capturer must use these, not the brief)

1. **Main without listeners does NOT enter free-scrolling.** `onIsAtEndChange(false)` (main `useTimelineFollow.ts:560-568`) returns early when `liveFollowUserScrollGenerationRef === anchorUserScrollGenerationRef`. Only `cancelTimelineLiveFollowForUserNavigation` breaks that equality, and with no listeners nothing calls it. So the mode stays `following-end`, `liveFollowEnabled` stays true, and **the early return also cancels and hides the scroll-to-end pill**. Consequence: on BEFORE in the hydration-held state, `button[aria-label="Scroll to end"]` never appears, even after scrolling more than a viewport. That is a second, visible BEFORE symptom (see W4).
2. **`tool_call` vs `tool_call_update` is not why tools added 0 px.** `runtimeModel.ts:1479-1500` maps both to the same `ToolCallUpdated` event. The only difference is `fallbackStatus: "pending"` for `tool_call`. `AcpSessionRuntime.ts:2795-2801` shows any not-yet-shown toolCallId, with no prior `tool_call` needed. The real cause is web row derivation. While a run is active, a running tool's `work-live` row (one line) takes the place of the one-line `working` indicator row (`MessagesTimeline.logic.ts:1552-1576`). A completed single tool becomes one collapsed `work` row (`:1752-1764`). **Tool output (stdout, multi-line or not) is never rendered unless the user expands the group**, so "completion with multi-line output" adds no height. Expect Δ ≈ 0 for a tool start and about one row for a finish. Measure it, don't assume it.
3. **A tool boundary snaps on BEFORE even when Δ ≈ 0.** `maintainScrollAtEnd.on.dataChange = true` (`MessagesTimeline.tsx:154-162`), and `doMaintainScrollAtEnd` re-pins whenever `isWithinMaintainScrollAtEndThreshold` is true. Replacing the row counts as a data change. This is the issue's literal "next tool boundary snaps" symptom. (Expected from the code; W1 confirms it with numbers.)
4. **A new assistant segment starts after every newly shown tool**, whatever the ACP `messageId` (`closeActiveAssistantSegment`, `AcpSessionRuntime.ts:2800`). So prose after a tool becomes a new message row below the tool row, and the fixture's shared `messageId` doesn't matter.
5. **LegendList's re-pin band subtracts the composer inset.** `distanceFromEnd_LL = contentSize − scroll − scrollLength − insetEnd ≤ 1 × scrollLength` (`react.mjs:693-701`), so the band measured in DOM px is a little wider than one viewport. In this matrix, "gap" always means the DOM value `scrollHeight − scrollTop − clientHeight`, the same quantity as `resolveTimelineIsAtEnd`, where ≤ 40 px means at end. The band may be re-evaluated after growth, so **require gap_before + Δ_phase ≤ 0.9 × clientHeight**, not just gap < 0.8 × clientHeight.
6. Smooth maintain only applies when `isWorking && !prefersReducedMotion`. **Force `reducedMotion: 'no-preference'`** in the browser context and record it. Under reduced motion the instant path runs. That still reproduces, but the run wouldn't match the brief's description.
7. **Main's own scroll listener is missing too.** The unattached set includes `handleScroll` (the upward-scroll detector), so on BEFORE no scroll event, iframe-chained or otherwise, can break follow either. On AFTER `handleScroll` also cancels on any upward scroll beyond 40 px, so the wheel row on AFTER is cancelled by both `handleWheel` and `handleScroll`.
8. The other follow-cancel paths on main that survive without listeners are composer PageUp (`handlePageScrollStart`), minimap select, find open, citation and position restore. None of them may be used in W rows.
9. The fixture scripts (`timeline-follow-fixture.ts`, the `acp-mock-agent.ts` changes) exist only in AFTER. Main has `acp-mock-agent.ts` without the fixture hook (`acpMockCancellationState.ts` is present). The overlay must add `apps/server/scripts/ev22788-acp-mock-agent.ts` and `ev22788-timeline-follow-fixture.ts` to **both** trees, byte-identical. Rewrite the mock's import to `./ev22788-timeline-follow-fixture.ts`, and point the provider `commandArgs` at the ev22788 mock. Record the sha256 of each.

## 1. Fixture requirements (overlay `ev22788-timeline-follow-fixture.ts`)

Phases are released one at a time by file barrier (the existing `waiting.json` / `<phase>.release` / `<phase>.done` protocol). Updates within a phase are paced 40 ms apart. Use one toolCallId per tool, `T1…Tn`.

| Phase | ACP updates | Expected visible effect | Probe Δ requirement |
|---|---|---|---|
| `entry` | `agent_message_chunk`, 100 paragraphs `P001–P100` (long) or 1 line (short) | long history, the run stays active | — |
| `t1-start` | `{sessionUpdate:"tool_call", toolCallId:"T1", title:"Check the harbor log 1", kind:"execute", status:"in_progress", rawInput:{command:"printf 'harbor clear\\n'"}}` | `working` row → `work-live` row (data change) | record Δ (≈0 expected; any value from 0 to 0.3·ch is OK) |
| `t1-finish` | `{sessionUpdate:"tool_call_update", toolCallId:"T1", status:"completed", rawOutput:{stdout:"harbor clear\n",stderr:"",exitCode:0}}` | collapsed `work` row "Check the harbor log 1", plus the working row again | 0 < Δ < 0.3·ch |
| `p1` | 1 paragraph `P101` (one chunk) | new assistant segment below the tool row | 0 < Δ < 0.3·ch |
| `t2-start`, `t2-finish`, `p2` | same as above with `T2` and `P102` | | same |
| `t3-start`, `t3-finish`, `p3` | same with `T3` and `P103` | | same |
| `resumed` | 2 paragraphs `P104–P105` | | 0 < Δ < 0.5·ch |

Hard rules for the fixture: no phase may add more than 0.5 × clientHeight at 1440×900, and no single chunk may be over 2 paragraphs. **Do not use expanded tool groups to add height**: expansion goes through the disclosure pin path (`suspendEndScrollMaintenanceForDisclosure`) and muddies the comparison. Paragraph text keeps a unique marker `Pnnn.` at the start of each paragraph so the anchor can be measured.

**P-probe (no recording, both builds, before any capture):** run the long fixture with no user input and no hold. After each phase settles, record `{phase, scrollHeight, Δ, gap}`. Pass: every `*-finish`/`p*`/`resumed` Δ > 0, every Δ < 0.5·ch, and gap ≤ 40 after every phase on both builds. If a tool-finish Δ is 0, report it and keep the phase as a boundary-only phase (it still has to show the BEFORE snap through `dataChange`).

## 2. Common driver rules

- Context: Chromium, `viewport 1440×900`, DPR 1, `reducedMotion:'no-preference'`, `colorScheme:'light'` for W rows. **Never use `page.clock`** on W rows. Fresh browser context per row, so there is no remembered timeline position.
- **Settle** = wait for `<phase>.done`, then wait until `scrollTop` and `scrollHeight` have been unchanged for 10 consecutive rAFs **and** at least 700 ms have passed since `.done`. That covers LegendList's 500 ms animated maintain plus one cycle. Take every sample only after settling.
- **Sample** `m()` = `{t, scrollTop, scrollHeight, clientHeight, gap, anchorId, anchorTop, pill, listeners}`:
  - `anchorId`/`anchorTop` = the first paragraph marker `Pnnn` whose rect top ≥ the timeline's top, and its `getBoundingClientRect().top`.
  - `pill` = `!!document.querySelector('button[aria-label="Scroll to end"]')`.
  - `listeners` comes from an init-script wrapper on `EventTarget.prototype.add/removeEventListener`. It records `{op, type, target: 'timeline'|'document', fnName, fromFollow}`. `fromFollow` is true when `new Error().stack` contains `useTimelineFollow`. Report `timelineFollow = net adds on .messages-timeline-scroll with fromFollow` (expected 4: scroll, wheel, touchmove, pointerdown) and `docKeydownFollow = net document keydown adds with fromFollow` (expected 1). **Do not count by name alone**: other `handleKeyDown`s exist, and LegendList adds its own scroll listener.
- **Hold (H):** `page.route('**/api/orchestration/threads/*/bounded', r => setTimeout(() => r.continue(), HOLD_MS))`, with HOLD_MS = 2500 on both builds. Register it before `goto(threadUrl)` and do a full page load to the thread URL. Record `holdStart`, `holdRelease`, and `framesDuringHold` from a rAF counter (must be ≥ 60, five times the 12-frame window), plus `loadingSeenDuringHold` (`[data-timeline-loading]` present at release minus 50 ms). After release, wait for `.messages-timeline-scroll` and settle, then sample listeners.
- **Wheel up (WU):** `page.mouse.move` to the timeline centre (x = timeline centre, y = 40% of the timeline height, which keeps it off the composer), then `page.mouse.wheel(0, -round(0.4·ch))` as a single event. Settle. **Gate:** `0.3·ch ≤ gap ≤ 0.5·ch`. If the gate fails, re-run the row from scratch; never top up with more wheel events.
- **Recording:** Playwright `recordVideo`. Narration time zero = the first video frame showing the driver overlay (a fixed 24 px box in the top-left whose colour changes per mark). Find it with ffprobe/frame extraction, then express every mark relative to it. Before submitting, check each narration mark against an extracted frame to within 0.2 s.
- PASS/FAIL numbers below use `ch` = clientHeight at that sample. "Snap" = gap ≤ 40 after settle. "Hold" = |ΔscrollTop| ≤ 2, `anchorId` unchanged, |ΔanchorTop| ≤ 2, and gap_after − gap_before = Σ Δ_phase ± 4.

## 3. Rows

| ID | Type | Precondition / setup | Steps | Sampled | BEFORE (main) expected | AFTER (PR) expected | Artifact |
|---|---|---|---|---|---|---|---|
| **R0 gate** | RECORDING | BEFORE only. Long fixture seeded, the run waiting at `t1-start`. Fresh context. | H(2500) → mount → settle → m(S_mount) → WU(0.4ch) → m(S_up) → release `t1-start` → settle → m(S1) → release `t1-finish` → settle → m(S2) → release `p1` → settle → m(S3) | listeners at S_mount; gap at all S | **Pass gate if** `timelineFollow = 0`, `docKeydownFollow = 0`, `framesDuringHold ≥ 60`, S_up gap within [0.3ch, 0.5ch], and **gap ≤ 40 at S2 or S3** (at least one snap after a phase that adds visible height). Also record S1 (the tool-start boundary snap expected from the `dataChange` re-pin). | — (BEFORE only) | video, narration.md, result.json |
| | | | **If R0 fails, STOP and report: do not capture any other row.** | | | | |
| **H1 hydration hold** | RECORDING (part of W1 clip) | both builds, same HOLD_MS | as in H, recorded in W1's clip with its own narration line | holdStart/Release, framesDuringHold, loadingSeenDuringHold, listeners after settle | `loadingSeenDuringHold=true`, frames ≥ 60, listeners **0 / 0** | `loadingSeenDuringHold=true`, frames ≥ 60, listeners **4 / 1**, every add timestamp > holdRelease | (in W1) result.json |
| **W1 wheel + tool boundaries** | RECORDING | both builds; long fixture, run waiting at `t1-start`; H(2500) | mount → settle → m(S0) → WU(0.4ch) → m(S_up) → release `t1-start`, `t1-finish`, `p1`, `t2-start`, `t2-finish`, `p2`, each followed by settle and m(S_k) | all m fields after each phase | **Snap**: gap ≤ 40 at S_k for every phase with Δ > 0, and at `t1-start`, where a snap is expected. `pill=false` throughout. | **Hold** at every S_k relative to S_up: scrollTop ±2, same anchorId, anchorTop ±2, gap grows by ΣΔ ± 4. `pill=true` from S_up + 150 ms onward. | video, narration.md, result.json |
| **W2 keydown on body** | RECORDING | both builds; long fixture, waiting at `t1-start`; H(2500); after mount, `click` on the text of paragraph `P100` (a row, **not** the scroll node itself; on AFTER a click on the scroll node would cancel follow through `handlePointerDown`), then `document.activeElement.blur()`. Assert `document.activeElement === document.body`. | Control step: release `t1-start` → settle → check gap ≤ 40 on both (the click must not have broken follow). Then `page.keyboard.press('ArrowUp')` N times with N = ceil(0.4ch / 40), settle. Gate: gap in [0.3ch, 0.5ch]; if Chromium did not scroll the timeline (gap < 0.3ch), use `PageUp` once and re-gate. Then release `t1-finish`, `p1`, settling and sampling m after each. | as W1, plus `activeElement` tag at each keypress | **Snap** after `t1-finish`/`p1` (gap ≤ 40). No listener handles the keys; the mode stays following. | **Hold** (as W1). `handleKeyDown` (fromFollow) is present. | video, narration.md, result.json |
| | | | Why it is kept: the code shows the document keydown listener is absent on BEFORE and present on AFTER (it is in the same effect), and body-focused keys bypass `pageScrollController`. | | | | |
| **W3 never-scroll control** | RECORDING | both builds; long fixture, waiting at `t1-start`; H(2500); no input after load | release every phase through `resumed`, settling and sampling m after each | gap, scrollHeight, pill | gap ≤ 40 after every phase, pill=false throughout, scrollHeight non-decreasing | same as BEFORE | video, narration.md, result.json |
| **W4 resume** | RECORDING | continue the W1 clip after `p2` (same page) | **A (pill):** if `pill` is true, click `button[aria-label="Scroll to end"]` → settle → m. **B (wheel):** if `pill` is false, `mouse.wheel(0, +2ch)` → settle → m. Then release `t3-start`, `t3-finish`, `p3`, `resumed`, settling and sampling after each. | gap, pill | pill is **never** present (corrections §0.1), so use the wheel path; gap ≤ 40 after every later phase (it was following all along) | after the pill click gap ≤ 40 and pill=false; **then gap ≤ 40 after every later phase** (follow re-armed through `onIsAtEndChange(true)`) | (in W1 clip) narration.md, result.json |
| **W5 fast-hydration control** | RECORDING | both builds; long fixture, waiting at `t1-start`; **no route hold** | goto → mount → settle → m (listeners) → WU(0.4ch) → release `t1-finish`… (same as W1 but without the hold) | listeners, gap | **Validity precondition:** listeners 4 / 1 on BEFORE. If BEFORE shows 0 here too, mark W5 INVALID (the app hydrated too slowly even without a hold) and report its time-to-mount in frames. Expected: **Hold**, same as AFTER. | Hold, listeners 4 / 1 | video, narration.md, result.json |
| **S0 transcript** | RECORDING (terminal) or text transcript | both builds; fresh barrier root | show `waiting.json` naming each phase, and show that `<phase>.done` appears only after `<phase>.release` | per phase: release time, done time, scrollHeight Δ from P-probe | no `.done` without a matching earlier `.release`; every Δ satisfies the §1 table; Δ < 0.8·ch for all phases | same | asciinema/typescript text + probe.json |
| **R-opt anchoring** (optional) | RECORDING | both; short fixture thread; send a new user message from the composer (mode `anchoring-new-turn`), H only on the initial load | after the new turn starts, WU(0.4ch), then release tool phases | gap | expected snap (`scrollToEnd` from `shouldReleaseTimelineAnchorForToolActivity`) | expected hold if the wheel cancels follow first; **record what actually happens, no pass claim** | video, narration.md |
| **ST1–ST8 at rest** | STILL | 4 cells: {1440×900, 390×844} × {light, dark}, each on BEFORE and AFTER. Seed identical homes per cell (same projects, same sidebar threads, same fixture thread completed through `resumed`). `page.clock.setFixedTime` is allowed here only. At 390, collapse the sidebar and navigate through the thread switcher. | load the thread with no hold and no input → settle → screenshot the full viewport | PNG | at end, pill absent | identical to BEFORE | B.png, A.png, **Diff0** (abs RGB ×8, count of pixels with max channel > 0), **DiffT** (magenta where max abs RGB > 8/255; count), bbox, `pixel-diffs.json`. Expected DiffT = 0 in the timeline region. Explain every non-zero DiffT bbox or mark it FAIL. |

Pass summary required for acceptance: R0 gate PASS, H1 BEFORE 0/0 vs AFTER 4/1, W1 and W2 BEFORE snap vs AFTER hold, W3 both follow, W4 AFTER re-follows, W5 both hold (or INVALID with the reason recorded), S0 and P-probe show the Δ bounds, and ST1–ST8 DiffT is explained.

## 4. Environment line (record verbatim at the top of the manifest and in each result.json)

`date (UTC) | MACHINE=$(cat ~/.agents/MACHINE) | uname -srm | node -v | vp --version | playwright + chromium version | headless=yes/no | viewport WxH DPR | reducedMotion=no-preference | colorScheme | BEFORE=ed3f965e07 tree-clean + overlay | AFTER=c4f9d0e114 (head 9ec682fce8) tree-clean + overlay | @legendapp/list 3.3.5 | overlay sha256 ev22788-acp-mock-agent.ts, ev22788-timeline-follow-fixture.ts | home dirs /tmp/ev-22788/homes/<build>-<row> | server/web ports per build | HOLD_MS=2500 | T3CODE_* unset`
