fix(agent-chat): cancel transcript motion on manual scroll (cx-65mwy)
Manual input now cancels transcript motion synchronously and blocks automatic restarts for USER_SCROLL_INTENT_MS = 1000 after the last input. Explicit rail/Next Turn/JumpPill/Find requests remain usable during that interval, but later manual input cancels their continuations. Bead: cx-65mwy. Head: bebdfd118dc7 (full head: bebdfd118dc7c88d72d32ee68a2e5eea9dcefd78).
Leo, verbatim:
There's some stupid shit where it fucking, like, when I'm scrolling, it tries to slowly send me to some designated spot. So, like, it sort of, I don't know, centers it on some bullshit. I don't know if this is related to the left scroll rail, prompt rail thing. Like, I've already said this before, but I'm just noticing it likewise.
Leo, verbatim (2026-10-28):
I'm still seeing the, like, you know, slowly scrolling to position bug from before. Like it's, you know, I scroll up and then it tries to slowly jump me towards the bottom.
Production reproduction and attribution
Real Chromium, CMUX_BROWSER_TESTS=1 bun test test/scroll-intent-rail.test.ts, using the existing VirtualTranscript harness and support/paintedFrames.ts / window.cmuxAfterPaint. An upward wheel (-80) arrives with a streamed snapshot before its scroll event. The test records every scrollTop setter stack and every painted frame through >1100ms.
| Run | Input top | Programmatic write after input | Painted result |
|---|---|---|---|
| Base 74964623b8aed2f707389c09d4d7ce6f5838217d (fails) | 5436 | settleFollowBottom, layout-effect caller: 5436 → 5724 (+288) | 5644 on all 66 frames: 208px opposite the wheel |
| Candidate (passes) | 5436 | 0 writes | 5436 → 5356; no reverse frame through 1115ms |
This identifies follow-bottom, not a rail jump, in this production repro. This base has no exact:false argument: attribution to that pending #25652 variant is not claimed. The early PR test injected an animation restart; this production test supersedes that probe. Direct nx-chat coordination sent production stack/numbers; final head/evidence delivery confirmed: message 01a17ac5-4854-7c21-9eec-30d46331cdbe, attempt 1, turn 01a17a81-6577-79e1-8ebc-9b20ba161981 (steered).
Height correction remains synchronous. The post-integration Chromium run compensates 257px while the visible anchor stays within 0.5px across eight painted frames: [-0.25,-0.25,-0.5,0.25,0,-0.25,-0.5,0.25]. Before restoring fractional carry, the same harness drifted to -1.75px. Repeated fractional measurements formerly drifted 1.75px; the existing caller now carries only its <=0.5px rounding remainder, resetting on manual input/session switch. preserveScrollAnchorTop and the deadband constant are unchanged. The shared anchor contract is retained from the merged base; no deadband constant or shared anchor helper changes.
Scroll inventory and gates
Audited all scrollTop writes, scrollTo, scrollBy, scrollIntoView, animation/landing loops under webviews/src/agent-session/acpmux/ (no scrollBy callers).
| Owner/path | Gate or classification |
|---|---|
App settleFollowBottom: layout and layout-driven onScroll | Layout checks isHeld(); onScroll classifies programmatic input through the same signal. Upward input releases follow before React's scroll event; #26088's sticky follow decision remains. |
| App Jump to latest shared runner/retarget | Explicit request. Manual intent cancels the shared ride and clears its target. Only a still-live explicit target may retarget. |
App Find initial row/range reveal, scrollRangeIn, inner-box reveal and rAF hold | New explicit Find request gets generation token; later input invalidates token, clears reveal window and cancels frame. |
| App delayed running-count/message reveal | Explicit request token created on reveal key, checked when row finally mounts. |
| App focusRow/keyboard navigation, send-to-latest, initial/openAt placement | Explicit navigation/send/session-open. Initial placement occurs while transcript is opening; later follow is gated. |
App preserveScrollAnchorTop caller, composer unlift | Same-frame place preservation exception; uses live reader offset during hold and bounded rounding remainder. No extra compensator/helper-contract change. |
App settleFold/keepFold; conversation/foldAnchor.ts | Explicit disclosure starts hold; later shared input clears it. Same-frame placement. |
conversation/useAnchoredCollapse.ts keepHeader rAF | Transcript delegates through ANCHORED_COLLAPSE_EVENT to the App hold above. Fallback loop belongs to non-transcript scrollers. |
threadMinimap/scrollMotion.ts animateScroll/step and settleScroll/landing rAF | Shared start gate + synchronous cancel subscription. Explicit rail/ScrollMap calls opt in; canceled animation never starts landing. |
ThreadMinimap.tsx jump/Next Turn/bookmark/scrub; ScrollMap.tsx click/seek | Explicit request; animated continuations use shared cancellation. Instant writes are synchronous. |
ChatMinimap.tsx pointer scrub/scroll keys | Synchronous explicit/manual navigation; no pending continuation. |
| ThreadMinimap listTop resets/anchor/follow and CSS transform glide | Separate rail-list scroller/visual positioning; does not scroll transcript. |
links.ts markRow/scrollToTurn/scrollToRow; pending revealWhenShown/markWhenDrawn | Explicit link or JumpPill starts; pending poll subscriptions cancel on later intent/unmount. |
needsYou/useNeedsYou.ts goTo / JumpPill | Synchronous explicit permission reveal; delayed turn reveal uses links cancellation above. |
conversation/Markdown.tsx footnote jump | Transcript clicks use the explicit shared runner and its cancellation/lifecycle gates; native scrollIntoView remains only for non-transcript footnotes. |
wheelPassThrough.ts, threadMinimap/PreviewCard.ts, conversation/gutterSelect.ts | Manual input forwarding/drag, not autonomous motion. Forwarded capture input and gutter reader callback mark shared intent. |
debug.ts startFling warmup/rAF; loadDebug.ts load_run | Explicit diagnostic start; request generation blocks later writes after manual input. |
streamDebug.ts initial bottom placement | Shared held gate. Subsequent streaming follows App gates. |
| DiffPanel, changes/useDiffKeys, changes/diffFind, inspector/ChatInspector | Explicit navigation in independent diff/inspector scrollers. |
| MarkdownField, composerScrollLane, ComposerQueue, Composer/SlashMenu/MentionMenu/ModelPicker/QuickModelMenu, NewTabPage, newtab/NewTabScreen | Independent editor/composer/menu/new-tab scrollers; no autonomous transcript target. |
| needsYou/NeedsYou focused row, subagents/BackgroundTaskTail, gallery-only scroll calls | Independent overview/tail or fixture scrollers; not transcript motion. |
#26114 shared-runner integration
The branch is now recut on nx-chat #26114, landed at d76bfca6eeabd8658c824d1bede56072fb62a974, and merged with the current origin/feat-cmux-next through edaa3a26c1164f87c264b79d9ce5d70c01c8fb0f (including landed #26135 and #26088). threadMinimap/scrollMotion.ts uses the shared runScroll for ordinary rail jumps and explicit wheel notches. Ordinary rides check userScrollIntent.isHeld() before starting and subscribe to its end signal so manual input cancels an in-flight ride. The wheel=true notch path deliberately bypasses that generic subscription; the existing installWheelGlide remains the owner of native-wheel versus notch arbitration and its cancellation/disposal behavior, preserving accumulation and reverse-notch tests.
Review follow-up
Prior-head Astra review: SHIP for edaa3a26c1164f87c264b79d9ce5d70c01c8fb0f..3bb7588d994ec30105fd55373dce3be5234eb174. Reviewer passed all 18 wheel-motion cases and 18 intent-attached variants, including accumulation/reversal/cancellation, and found no recursive cancellation path. Broader browser results were not treated as green.
Prior-head independent Astra review: SHIP at dd123250f79ccbd9764d3ffe97a5eb8df600cab9. Reviewer verified all incremental blob hashes, resolved every finding, and passed 27 motion/timer cases plus 90 real-timer callbacks without notification while held.
Earlier Astra reviews found three edge cases, all fixed: pointer/touch movement only releases follow when the movement is upward, so a downward/clamped drag can continue following at the bottom; lost pointerup is recovered when a pointermove reports buttons=0 or the window blurs, with listeners removed on cleanup; and a debug fling cancelled during warm-up clears its running flag. The regression harness covers both lost-drag paths. Integration also restored the Find/count request-generation guards: with an outstanding reveal, manual input followed by the target arriving formerly moved 464px; both now move 0px. New explicit requests still work during the hold. The parked-stream instrumentation was updated for the restored caller expressions.
Review follow-up also excludes a browser clamp from live anchor selection (the failing shrink fixture overcorrected by 929px; fixed result preserves the expected anchor). A final streamed chunk during the input hold resumes once quiet without another token. Fake timers cover repeated input, held drag, release, subscription cleanup, and the actual placement pass with no later token. The latter caught a missing effect dependency: before 4799.5 remained short of the 5156 end; fixed placement reaches 5156 exactly at quiet expiry. Early fractional callbacks recheck the deadline and rearm; the regression verifies no notification at 999.25ms and one after 1000.25ms. These regressions grew the change beyond the original ~600-line target (including focused review regressions and evidence).
The last inventory pass routes transcript footnotes through the shared runner. A minimal Chromium probe found native cancellation still paints one queued 2→10px step for same-offset instant scrollTo, scrollTop assignment, and nudge/restore alike. Footnotes now retain nearest-edge placement and reduced-motion behavior using explicit animateScroll; manual input cancels synchronously. Session/send/Find transitions stop that shared ride too. Non-transcript footnotes retain their prior behavior. The regression clicks an actual rendered footnote and checks twelve painted offsets after manual input. Final-head Astra review: SHIP at bebdfd118dc7c88d72d32ee68a2e5eea9dcefd78; nine focused geometry checks passed and the docked oversized-note finding is resolved. Focused browser rerun 40551824540, source bebdfd118dc7c88d72d32ee68a2e5eea9dcefd78, is still pending.
Lifetime, performance, and validation
One WeakMap signal per scroller; timestamp reads allocate nothing. One shared quiet-expiry timer exists only while subscribed and not dragging: each input reschedules it, a press clears it, and reset/last unsubscribe clears it. At expiry it requests one placement commit only if follow was deferred and the reader still intends to follow; no later token is required. Drag stays held until release, then starts the one-second interval. Cleanup removes node wheel/pointerdown/touchstart/touchmove and document pointermove/pointerup/pointercancel/touchend/touchcancel/keydown listeners and the window blur listener; reset invalidates pending requests. App subscription cancels Find rAF and the shared jump ride, releases fold hold, and is removed on unmount. Find rAF also cancels on unmount. Rail animation/settle completion, cancellation and unmount remove subscriptions/input listeners and rAF. Link poll completion, cancellation, replacement and transcript unmount remove subscription/timeout. No O(rows) work added per token; anchor bookkeeping is O(1).
- New Chromium regression: wheel/stream interleaving + per-painted-frame direction, same-frame anchor preservation, fake-clock 999/1000ms boundary and drag release, explicit-vs-automatic animation start/cancel, pending JumpPill cancellation and new explicit request.
- Native scrollbar experiment with Chromium
ignoreDefaultArgs: ["--hide-scrollbars"]: 16px scrollbar, thumb drag emitted pointerdown → scroll events → pointerup (no pointermove); held drag and onScroll follow-state path handle it. bun run check: passes fixed-waits, layers, design tokens, vp formatting/lint/type check (2805 files),bunx tsc --noEmitand protocol generation. Existing unrelated system-blue warnings only.- Pre-final integration focused Chromium suite: 41 pass, 0 fail. Final-head run 40541928488 passed the production wheel/anchor regression, Find/count/clamp/expiry cases, and parked-stream tests: 3/10/20/40/80px each have 0 flips, 0 writes and 0px movement; pinned streaming has 0 flips, 19 follow writes, +698px and a 0px end gap. The combined process then exited 1 during the broader painted-geometry suite without an assertion report, so this is not an overall green receipt. Separate-process recovery 40543084667 on unchanged head passed 45 unit/rail cases and all 3 Chromium scroll-intent cases, then reproduced the painted-geometry process exit. Pinned-bottom 40544071263 reports 1 pass/5 failures; follow was not reached. 40545331173 confirms the same five pinned-bottom failures on merged base
edaa3a26, with 1 pass/5 fail on both base and candidate. The three transcript-follow cases pass on both base and candidatedd123250; pinned failures are pre-existing warnings, not a regression attributed to this PR. The painted-only diagnostic 40544402577 reports runner success but stops at its 10px measurement without a final test/exit marker: inconclusive, not a passing test receipt. - Prior-head remote Chromium lane
40536173302(source087e4b73d935f85c17c0b205286dc5ea05b96cef) passes the focused rail, transcript-follow, jump and wheel suites. - Real Chromium (
CMUX_BROWSER_TESTS=1): scroll-intent production, explicit request cancellation and JumpPill cancellation pass; painted frames stay at or below the post-wheel top and the anchor correction preserves the visible row within 0.5px. Wheel-glide Chromium cases pass, including accumulation/reverse and native-wheel arbitration. WebKit is unavailable on this host because the Playwright WebKit executable is not installed.
Gallery health 40541750117 is red on composer/model-picker/new-tab selectors and timing budgets. The relevant unchangedstreaming-text#jump-during-streamfixture waits for its last delta at 16871.467ms, but has the default 2000ms ready budget; measured 17425ms. No gallery allowlist/budget changes are made here.
Reference inspected: cmux-design-references/apps/claude/live/transcript-markdown-rendering.png (README and Claude/Codex NOTES read). This changes scroll behavior only; no styling and no new animation.
Takeover update (2026-10-30)
The original lane went idle, so this PR was claimed by a new owner. On top of the original change:
- Brought in
feat-cmux-nextat463abe2a1de5. ResolvedscrollMotion.ts: base'srailOwnsWheellanding filter (#26520) and the intent hold/cancel subscription now both apply. Both sides' tests are kept intranscript-find-follow.test.tsx. userScroll.ts: a wheel over a rail that scrolls itself is not transcript input (railWheel.ts), so it neither holds follow nor cancels a jump's landing.App.tsx: a send, or a wheel or key that moves down while following, is an explicit follow request that lasts until the next input. Before this, the hold deferred follow-bottom after any input. The view then ended short of the bottom after a send right after a scroll (transcript-pinned-bottom"a send puts them at the bottom") and after content shrank under a downward wheel (transcript-viewport-resize). An upward move still releases follow synchronously.- Removed
docs/evidence/scroll-intent/from the tree. One of those images was a private chat capture and must not be in a public repo. The frame-data links that used to be above pointed into that folder.
Base still has the bug. Fleet Linux (Chromium, CMUX_BROWSER_TESTS=1), bun test test/scroll-intent-rail test/user-scroll-quiet test/transcript- test/thread-minimap test/thread-rail test/jump-scroll test/agent-pane-footnote test/agent-pane-reveal test/scroll:
| Tree | pass | fail |
|---|---|---|
Base sources 463abe2a1de5 + this PR's tests | 791 | 12 (wheel repro, footnote, explicit-request, JumpPill, find/count reveal, clamp, quiet expiry, 3 parked-stream) |
Head 7cd65a196ea8 | 804 | 0 |
On base, the wheel repro now fails at its same-frame anchor assertion (scroll-intent-rail.test.ts:163) rather than at a reverse frame. The rest of the base reds above are behaviors this PR adds. bun run check passes at this head (0 errors, no type errors).
Observed / Not run
- Observed: both fleet runs above, and
bun run checkon the fleet at this head. - Not run: a tagged native build (this PR changes webview scrolling only). Of the WebKit suites, only
transcript-pinned-bottomran here (it passed for webkit at this head).
Gallery health reds (composer#file-not-attached, new-tab-headline#picked, new-tab#attachment-menu, and ready budgets) are in states this PR does not touch. Release-notary was red on base's chat-toolbar-unified.md; base fixed that in fa63553.
Review follow-up at 7cd65a196ea8 (router-review BLOCK)
- Drag recovery skipped the quiet interval: fixed. A pointermove with no buttons, or a window blur, now ends a drag by starting the one-second quiet interval, as a pointerup does (
userScroll.tsrelease()). Regression testuser-scroll-quiet.test.ts"a drag ended by pointermove without buttons / window blur still holds for the quiet interval": 2 fail before the fix (fleet run 9a2e24be), pass after. - Visual review: synthetic before/after stills and a per-frame chart are below ("Before / after"). The old reference/candidate image was removed with
docs/evidence/because it held a private capture.
At head 6eff5f8d2254: the same fleet suite shows 806 pass, 0 fail, and bun run check passes (0 lint errors, no type errors, fmt clean).
Before / after (synthetic fixture, real Chromium, 2x)
The reader clicks a footnote reference, then presses in the transcript while it glides toward the note. This is the "slowly send me to some designated spot" case: base keeps gliding to the note after the press, and this PR stops on the frame of the press. Captured with the transcript-follow harness on fleet Linux (fixture text only). The harness always draws the pane's dark theme, so the stills are dark only; the chart is in both.
Before (feat-cmux-next 463abe2a1de5): once the glide settles (about 1.2 s), the view has carried on past the press to the footnote

After (this PR, 6eff5f8d2254): the view stays where the reader pressed



The landing owner viewed these after images. Nothing on screen is restyled; only when the transcript moves by itself changes.
Performance
This is a behavior fix; no timing claim. The numbers are distance moved, per painted frame, from the same harness on fleet Linux Chromium (scroll-intent-rail.test.ts and the footnote-ride capture above).
| metric | before | after | delta | harness or run link |
|---|---|---|---|---|
| px the footnote glide moves after the reader's press | 1843 | 0 | -1843 px | transcript-follow harness, footnote ride (fleet run 99b489fb vs head capture) |
| failing tests in the scroll/transcript/rail suite | 12 | 0 | -12 | bun test test/scroll-intent-rail ... test/scroll (fleet runs 3e76eaea vs a96c81bb) |
The intent hold allocates nothing per frame or token: one WeakMap entry per scroller and one quiet timer while subscribed. Stop conditions: the timer clears on input, press, reset and the last unsubscribe; each follow request expires at the next input.
Base merge at 1018afc3dafa
Merged feat-cmux-next after #28494, #24131, #25766 and #26635 landed, keeping their behavior: scrollMotion keeps base's railOwnsWheel input filter, and the minimap click keeps base's instant jump (now an explicit ride, so the intent hold never cancels it). One new red came with the merge, from #26718's "a parked reader's processed or non-upward input does not double-apply a row above that shrank" test: the placement pass anchored on the live offset for the whole intent hold. It now anchors on the live offset only between an upward reader input and the scroll event that applies it (upwardPending), which is what #26718 expects.
Base reds by name at feat-cmux-next 6a174aa11d4b (fleet run): bun run check fails on check:design-tokens, check:layers, vp fmt --check and vp lint; "Previous Turn and Next Turn step the transcript through its prompts" fails 3/3.
Second base update and review follow-up at ce6aee9014d7
Brought in feat-cmux-next again after #27365 (upward scroll recovery). #27365 adds its own readerUpPending and the scroll-origin coordinates, which replace this PR's upwardPending and fractional anchorCorrection. The placement pass is now base's, plus this PR's intent hold and follow request. Fixed the router-review BLOCK at fc1f00de (a footnote inside a capped, scrolling tool result was never revealed): each scrolling ancestor below the transcript now brings the note into view at once, and then the transcript rides to it. Red/green on the fleet: the new test fails with the old Markdown.tsx and passes with the fix.
Fleet results at that tree: 880 pass. The only suite red is "Previous Turn and Next Turn step the transcript through its prompts", which fails 3/3 on base. bun run check fails only on check:design-tokens, check:layers, vp fmt --check and vp lint, which fail the same way on base. vp fmt makes no change to this PR's files.
Changelog
- Fixed: scrolling the agent chat no longer gets pulled back toward the bottom or a jump target while you scroll.
Agent session: agent session