fix(web): hold chat position through streaming tool calls#22788

draft11
main
fix/issue-20366
opened6d agobyJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 5d ago

Addresses #20366.

When thread hydration takes longer than the scroll hook's twelve-frame retry window, the timeline mounts without its input listeners. Scrolling upward then leaves live follow enabled, and the next tool boundary snaps the view to the bottom. Attach and clean up those listeners when the actual scroll node mounts or changes, using a stable list ref callback. This removes the frame retry loop and adds no continuous repaint work.

Reproduced on 74541bab17 in an isolated web dev server: a long GPT-6-Astra turn followed by sequential sleep calls jumped from scrollTop 2147 to 8136 at tool activity (height 8884, viewport 748).

At ca449ec80e, native PageUp disables follow. Position stayed at 11572.5 for 65 seconds through tool transitions while content height grew from 14439 to 15586. Clicking the scroll-to-end pill restored follow and subsequent output tracked the bottom.

Before: Before: scrolling upward snaps back at tool activity

After: After: PageUp holds position through tools and explicit resume follows again

Validation: added one regression in the existing timeline browser test file: keep the loading placeholder beyond the old retry window, mount the list, and verify upward input disables follow. It fails on the original hook and passes with this change; all five browser tests and six anchoring unit tests pass. Targeted lint/format and web typecheck pass.

Draft: independent proof audit remains INSUFFICIENT. Matched settled controls are complete, but both revisions pass them; actual-app delayed hydration has not been reproduced under matched conditions. Capture matrix.

Evidence status: eight width/theme fixture pairs, 42 matched checkpoint pairs and 84 Diff0/DiffT images; 28 pairs are pixel-identical. Short fitting-content controls, old-node cleanup/replacement and same-node thread switching pass in the real app. An independent rebuild produced an exactly identical detached head image. These settled controls pass on base too and do not establish the hydration race. The committed opt-in seeder uses normal ACP message/tool updates and supported thread-start commands.

Independent proof audit — verdict INSUFFICIENT. Rebuild recording.

Capture provenance: before production 74541bab17, after production ca449ec80e, fixture head f4bce0f27c. Final da7573a5fc only makes two fixture constants private and formats their array; generated data and production behavior are unchanged. Focused server unused-export analysis, fixture lint and format pass after that correction; final CI is pending.

Environment: Chrome 154/Linux, CSS viewports 1440×900 and 390×844, light/dark, DPR 2, default font scaling. Preview PNGs cap width at 1280 (wide output 1280×800). Runtime clocks remain live; cached timestamps and server elapsed durations were not fully frozen. All differences are retained, not masked. Nonzero pairs include time/duration labels, live opacity/caret phase and isolated-home placeholder differences; they are not presented as listener-fix effects.

Unverified acceptance: matched real-app delayed hydration; native wheel/trackpad momentum, touch and scrollbar drag; supplemental composer/iframe/reduced-motion controls; exact lifecycle listener counts/repetition; fully matched shared clock; verified phase ranges within recordings. Electron shell remains untested. Native mobile uses separate follow logic and is unchanged. No continuous repaint loop is added.

Model/harness: GPT-6.1-Sol through Codex in T3 Code; implementation delegated to Claude Fable 5.1.


Superseded: the evidence above (recordings, matrix, audit, provenance at 74541bab17/ca449ec80e/f4bce0f27c) is superseded by this section. The earlier text is left as written.

Evidence

Head captured: after = 6f2d6c63e3 (main merged in), before = merge-base 8d3b061121. The current head 9ec682fce8 has a tree identical to 6f2d6c63e3.

Result: the delayed-hydration snap is still not reproduced in the real app. The listener precondition is confirmed: after the hydration hold, the timeline has 0 input listeners on before and 4 (+1 keydown) on after. The scripted sequence could not show the snap on before, though. The driver scrolled 1484 px with PageUp and 1200 px with the wheel, both beyond the list's one-viewport re-pin threshold (maintainScrollAtEndThreshold={1}, 848 px), and the fixture's tool phases add no height. The no-scroll control (W3) also fails on both revisions, because one prose chunk adds 1117 px. Both audits put this down to the fixture and driver, not the app. These recordings are a record of that attempt, not proof of the fix.

Slow hydration (labelled): a test-driver-only Playwright route holds GET /api/orchestration/threads/<id>/bounded for 2.57–2.95 s (143–178 frames), identically on both revisions, with no source change. The WebSocket hold never engaged (wsRequestsHeld = 0).

RowBeforeAfterVerdict
S0 server fixture transcriptS0-B.txtS0-A.txtPASS: same fixture sha256; phases advance only on release
W1 PageUp mid-stream, slow hydrationrecordingrecordingNOT REPRODUCED: before never snaps; the scroll distance is beyond the re-pin threshold
W1p W1 + scroll-to-end pill resumerecordingrecordingINCONCLUSIVE: after ends 160 px short at the resume sample (sampling race; before's rerun also showed 160)
W2 real mouse wheel uprecordingrecordingNOT REPRODUCED: same cause as W1; handleWheel is attached only on after
W3 no-scroll controlrecordingrecordingFAIL on both: fixture prose chunk is larger than one viewport
Stills at 1440, light and darkbelowbelowUNVERIFIED: the sidebar thread lists differ between the isolated homes
Stills at 390nonenoneUNVERIFIED: not captured (out of time)

Uploads (ghdrop upload -m output, verbatim):

manifest.md W1-B.mp4 W1-A.mp4 W1p-B.mp4 W1p-A.mp4 W2-B.mp4 W2-A.mp4 W3-B.mp4 W3-A.mp4 W1-A.narration.md W1-B.narration.md W1p-A.narration.md W1p-B.narration.md W2-A.narration.md W2-B.narration.md W3-A.narration.md W3-B.narration.md S0-B.txt S0-A.txt pixel-diffs.json phase-range-verification.json ST1-C-held-A.png ST1-C-held-B.png ST1-C-held-Diff0.png ST1-C-held-DiffT.png ST1-C-mounted-A.png ST1-C-mounted-B.png ST1-C-mounted-Diff0.png ST1-C-mounted-DiffT.png ST2-C-held-A.png ST2-C-held-B.png ST2-C-held-Diff0.png ST2-C-held-DiffT.png ST2-C-mounted-A.png ST2-C-mounted-B.png ST2-C-mounted-Diff0.png ST2-C-mounted-DiffT.png

What else changed between the two captures, unrelated to the fix: sidebar thread counts and statuses (each isolated home accumulated a different number of seeded threads), the ws-B/ws-A workspace labels, server-stamped durations, and about 1.2–1.8 s of offset between the narration timestamps and the video timeline (the scroll states in the frames match the sampled numbers).

Environment: Linux 6.1.102 x86_64, Node 24.22.1, Playwright 1.60.0, Chromium headless shell 148.0.7778.96, 1440×900, DPR 1, en-US, UTC, page clock fixed at 2026-01-01T12:00Z. Isolated /tmp homes, seeded by apps/server/scripts/timeline-follow-fixture.ts (never ~/.t3/userdata). Before = 8d3b061121 with the two fixture scripts overlaid; after = 6f2d6c63e3.

Driver: timeline-follow-evidence.ts. It was committed in de48987b4a/0af183bec7 and removed in 9ec682fce8: it failed the server typecheck (Effect global-timer rules, DOM globals under the Node tsconfig), and the audit found it unable to exercise the snap.

Verification log
  • Main merged in by a merge commit at 6f2d6c63e3. There were two conflicts:
    • ChatView.tsx: main's ThreadTimeline now supplies the entries, so only the PR's onScrollNodeChange was kept.
    • MessagesTimeline.browser.test.tsx: the follow test and main's automation tests were combined.
  • Tests on the merged head:
    • MessagesTimeline.browser.test.tsx: 11/11 pass.
    • timelineScrollAnchoring.test.tsx: 6/6 pass.
    • apps/web typecheck: clean.
    • Targeted lint/format: clean.
  • Proof audit pass 1 produced the matrix: delayed hydration, native wheel, a no-scroll control, and phase ranges.
  • Proof audit pass 2 (report): INSUFFICIENT.
    • SHAs match.
    • S0 and the hydration hold pass.
    • W1/W2 "not reproduced" comes from the driver, not the app: scroll distance > 0.8 viewport, the tool phases add 0 px, and the 500 ms sample races the smooth follow.
    • W3 fails because of the fixture.
    • The narration runs about 1.2–1.8 s ahead of the video.
    • The auditor reran W3 on before in isolation and got non-deterministic partial follow.
  • To make before snap next time:
    • Scroll up less than 0.8 viewport and assert that before releasing phases.
    • Emit prose in chunks smaller than a viewport.
    • Make the tool phases add a visible row.
    • Sample only after scroll settles.
    • Sync narration from the first overlay frame.
  • CI: on 0af183bec7, Build and Typecheck, Check, and Lint failed because of the driver. The remaining 24 checks passed. CI is pending on 9ec682fce8, whose tree equals 6f2d6c63e3.
  • Unverified:
    • Real-app delayed-hydration snap on before and hold on after: the fixture and driver could not reach the code path.
    • Stills at 390: not captured.
    • Attribution of the stills at 1440.
    • Touch, trackpad momentum, scrollbar drag, Electron: out of scope.
    • Native mobile: separate follow logic, unchanged.
  • Model/harness: Claude Opus 5.5 (evidence lead, auditors, capturer) through Claude Code in T3 Code.

Superseded (round 2, 2026-10-22): the round-1 ## Evidence section and Verification log above (recordings W1–W3 and the stills at 6f2d6c63e3/8d3b061121, the round-1 matrix and its INSUFFICIENT audit) are superseded by this section. The earlier text is left as written.

Evidence

The bug reproduces on main, and the PR fixes it. On main, the timeline's follow listeners never attach when thread hydration outlasts the 12-frame retry window (0 timeline + 0 document keydown listeners; the PR has 4 + 1). A wheel scroll up of 0.40 viewport during a streaming turn then snaps back to the bottom at the next tool boundary, because LegendList keeps re-pinning within one viewport of the end. With the PR the position holds exactly, and the scroll-to-end pill resumes follow.

Builds: before = main ed3f965e07. After = c4f9d0e114, a local-only merge of head 9ec682fce8 with main (never pushed; its tree differs from main only in this PR's 6 files).

Slow hydration (labelled, test-driver only): a Playwright route holds GET /api/orchestration/threads/<id>/bounded (the HTTP load path) for 2.9–3.2 s on both builds. No source change.

RowBefore (main)After (PR)Verdict
R0 gate: main must misbehavelisteners 0/0; wheel up to a 339 px gap (0.40 of 848 px); gap 0 after t1-start; video, narration—REPRODUCED on the first run
H1 hydration hold0/0 listeners after the hold4/1, all attached after the hold releasedPASS
W1 wheel up 0.40 viewport, tool boundaries + prosegap 339 → 0 at every phase; pill never shown; video, narrationscrollTop unchanged and anchor paragraph P084 fixed; gap 339 → 395 → 450 (+56, +55 px); pill shown; video, narrationPASS
W4 resume (end of the W1 clips)no pill, so a wheel down to the end; followspill click → gap 0, then follows through every later phasePASS
W2 ArrowUp ×9 with focus on bodygap 360 → 0; videogap 360 → 360 → 416, position held; videoPASS
W3 never-scroll control (chunks ≤ 2 paragraphs)gap 0 after all 10 phases; videogap 0 after all 10 phases; videoPASS (both follow)
W5 fast-hydration control (no hold)listeners 4/1; holds like after; videoholds; videoPASS: the bug is hydration-specific
S0 fixture barrier transcripttranscript: each phase delivered only after its releasesamePASS
Stills 390×844 light/darkpixel-identical (Diff0 0, DiffT 0)
Stills 1440×900 light/dark0 px differ in the main pane; all differences are in the sidebar thread list (see below)

At-rest stills (completed thread):

What else changed between captures, unrelated to the fix:

  • The sidebar thread lists differ: before's isolated home has two extra fixture threads, and the relative times and "Working" labels differ. This accounts for every differing pixel at 1440.
  • Thread, environment and barrier IDs differ per run.
  • While a fixture tool is running, its live row reads "Unsupported…" on before and "Tool" on after. The stored data is identical, no measurement changes, and the cause is unexplained.
  • The composer switches to its compact layout off the end on both builds.

Environment: fs-20, Linux 6.1.102 x86_64, Node 26.12.1 (servers, seeding) / 24.22.1 (driver), Playwright 1.60.0, Chromium headless shell 148.0.7778.96, 1440×900 (stills also 390×844), DPR 1, en-US, UTC, reducedMotion no-preference, @legendapp/list 3.3.5. Each build has its own isolated home under /tmp (never ~/.t3/userdata).

Fixture: two untracked overlay files, byte-identical in both trees and never committed: ev22788-acp-mock-agent.ts (504242bde2c6789347bcba9854287a0eac60c69f3ee3be5b62d4169b89ec38b8) and ev22788-timeline-follow-fixture.ts (9400062b06a1bd4fefce4d7cdcd4309439e3517fa7012d036fe6c00ff20d8cc8).

Driver: ev22788-driver.mjs, sha256 49c57bfdf6d423c76aad464f23468c3d57a3843366f085cae04304b6405ddf43.

Manifest: every recording, narration, result/sync JSON, verification frame, helper script hash, and the per-phase height probe.

Verification log (round 2)
  • Why round 1 could not fail on main, confirmed in code:
    • Composer PageUp cancels follow through pageScrollController on main too.
    • LegendList re-pins only within maintainScrollAtEndThreshold(1) × viewport, and round 1 scrolled 1200–1484 px.
    • Round 2 uses a wheel scroll of 0.40 viewport (and ArrowUp with focus on body), with prose chunks of 1–2 paragraphs.
  • Proof audit pass 1 produced the matrix, including an R0 gate: stop if main does not snap. R0 snapped on the first run.
  • Capture follows the matrix. Deviations, none of which changes a verdict per audit 2:
    • one isolated home per build, not one per row;
    • the height probe ran with the hold on;
    • the 390 stills were opened by direct URL;
    • the hold measured 2.9–3.2 s rather than 2.5 s.
  • Fixture caveat: both builds stored the fixture's ACP tool_call updates as "Unsupported ACP update" rows. The tool phases are therefore data-change boundaries that add 0 px, not real tool rows. Main still snaps at them through LegendList's dataChange re-pin, and the prose phases add real height (+55/56 px).
  • Narration t=0 is the first video frame showing the overlay. 7 of 9 videos agree within 0.053 s; the two outliers use the observed frame time.
  • Proof audit pass 2 (report): VERDICT: SUFFICIENT.
    • SHAs, overlay hashes and the driver hash were verified.
    • 10 frames from before-R0, before-W1 and after-W1 match the narration.
    • An independent rebuild of W1 on fresh homes reproduced every number: before 0/0, 339 → 0; after 4/1, 339 → 395 → 450 at scrollTop 4806, anchor P084.
  • Unverified / not captured:
    • the anchoring-new-turn path (a user turn sent with the page open);
    • real (supported) ACP tool rows;
    • touch, trackpad momentum and scrollbar drag;
    • Electron;
    • native mobile, which has separate follow logic and is unchanged.
  • Nothing was committed or pushed in round 2; the main merge stayed local. No CI was polled.
  • Model/harness: Claude Opus 5.5 (evidence lead, auditors, capturer) through Claude Code in T3 Code.

🤖 Generated with Claude Code

Fixes #20366 GitCafe: https://git.cafe/pingdotgg/t3code/issues/20366

Checks

2 failing · 19 successful · 11 skipped
Some checks failed

Failing2

9ec682f

Added 1 commit

  • test(web): cover overflow while a tool row awaits layout95c7ba7
Synced from GitHub

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire4.9 KiB5.0 KiB+30 B (+0.6%)6.8 KiB✅
CodexThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
CodexLive turn WebSocket wire1.2 KiB1.2 KiB+30 B (+2.5%)2.0 KiB✅
CodexLive turn WebSocket decoded20.8 KiB20.9 KiB+41 B (+0.2%)29.3 KiB✅
CodexLive turn messages12+1 (+100.0%)8✅
ClaudeTotal thread wire5.0 KiB5.0 KiB−24 B (−0.5%)6.8 KiB✅
ClaudeThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
ClaudeLive turn WebSocket wire1.2 KiB1.2 KiB−24 B (−2.0%)2.0 KiB✅
ClaudeLive turn WebSocket decoded21.2 KiB21.2 KiB−41 B (−0.2%)29.3 KiB✅
ClaudeLive turn messages21−1 (−50.0%)8✅

Baseline: 8d3b061 · PR result: 9ec682f · Source CI: failure

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

Added 10 commits

  • fix(web): scrolling up keeps its place when a tool call startse4e1b8c
  • fix(web): timeline follow listeners attach once the list existsca449ec
  • test(web): add reproducible timeline streaming fixture8dec874
  • test(web): isolate synthetic provider session identitiesf1769db
  • test(web): deliver fixture paragraphs at explicit barriersf4bce0f
  • fix(server): keep timeline fixture constants privateda7573a
  • Merge remote-tracking branch 'origin/main' into fix/issue-203666f2d6c6
  • test(web): add timeline follow evidence driverde48987
  • test(web): add timeline follow evidence driver0af183b
  • test(web): drop timeline follow evidence driver9ec682f