You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Since #4105 and #4206 landed (2026-08-28 to 08-30), fourteen PRs have changed transcript scrolling: #4414, #4417, #4549, #4560, #4577, #4793 (closed), #4809 (closed), #4831, #4883, #4886, #4911, #5001, #5057, #5147 (open). Each restored one behavior that the two refactors had removed or left undefined, and each did it by adding a mechanism at the seam nearest the symptom. The result on current main:
"Where is the reader" has six representations that cannot be derived from each other: #readingAnchorSequence in the Main replica (an eviction protection point), transcriptReadingAnchorBySessionRef in the Renderer (a cross-Session bookmark), authority.pinned, DOM scrollTop, the Prompt Rail activeSelection, and firstVisibleTurnId(). They align only through the turnId string. Any layer without a turnId (an overlay-only live Turn) takes a special path: readAt with anchor === null, #findTurnSequence, isLiveTurn.
Navigation staleness is guarded by four mechanisms with similar but not identical semantics: navigationVersion in the Renderer controller, a copy in preload, a copy per consumer in the Main observer, #navigationToken in the replica, plus object-identity checks in the controller and the history gate. transcript-navigation-race.test.ts holds eight cases for this alone.
The resident budget is 10 Turns / 512 KiB (transcript-contract.ts), decided in the Main process, with no relation to the viewport. A wide window with short Turns holds less than one screen and pages on the first gesture; a single oversized Turn exceeds the budget and needs an exception (desktop-transcript-replica.ts#evictToBudget). fix(desktop): stabilize prompt rail history scrolling #5147 widens the budget on one side for one entry point (loadAround) and narrows the other side by the same amount.
Every range command, including a pure "record the reading position" readAt, sets resetRequested in the observer and resends the whole resident range as a reset snapshot. The marginal cost of scrolling is O(resident range), not O(change).
None of this is a defect in any single PR. It is what happens when the pixel-synchronous side (the DOM) and the page-asynchronous side (Host → Main → Renderer) both decide what the reader can scroll into, and neither owns the reader's position.
Why the two refactors left this open
#4105 collapsed the JS writers of scrollTop to one boolean and handed "keep the reader where they were" to native scroll anchoring, stated as free and already correct. It is neither while the transcript is pinned (anchoring and the pin are opposite instructions, #4269), nor when a transient row or an estimated-height block is the anchor node (#4259, #5147), nor at offset zero. The writer count went down; the browser was promoted to co-writer without a protocol.
#4206 moved the resident bound from the Renderer to the Main replica and deleted the virtualizer without a replacement for what it had implied: forward paging (#4886), the reading position across a Session switch (#4414), the rail's DOM markers (#4417), and the bookmark/gate/version machinery that every later PR then rebuilt. Its own ledger records runtime state owners going 3 → 2, not → 1. #2913 §4 still lists the Renderer-side bounded window as open work; #4206 removed the previous attempt at it rather than finishing it.
Current boundary
flowchart LR
H[Runtime Host<br/>bounded pages] --> M[Main replica<br/>10-Turn window, eviction,<br/>reading anchor, intent]
M -->|reset snapshot per command| R[Renderer store<br/>projection of Main's window]
R --> D[DOM<br/>scroll authority + native anchoring]
D -->|readAt turnId| M
Loading
Two processes decide the window; three places hold the reader; the browser corrects positions no process asked for.
Direction
One owner per fact, and the owner is the layer that can measure the fact.
flowchart LR
H[Runtime Host<br/>bounded pages, watermark] --> M[Main<br/>page cache + overlay + global LRU]
M -->|pages and watermark, tagged with the<br/>Renderer's navigationVersion| R[Renderer store<br/>the only transcript window]
R --> D[DOM<br/>scroll authority]
D -->|reader position: turnId + offset| R
Loading
1. The Renderer owns the window
DesktopTranscriptRangeStore becomes the only holder of the resident range. It requests pages through the existing loadBefore / loadAfter / loadAround and evicts by its own rule: keep every Turn that intersects the viewport extended by N screens on each side, subject to a byte ceiling that never evicts a Turn intersecting the viewport. Viewport geometry comes from the scroll authority, which already observes the scroller and its children. Budgets are expressed in screens, not Turn counts.
The Main replica keeps what only it can do: assemble Host pages and fragments, project the RuntimeEvent overlay, settle overlay coverage, advance the durable watermark, and reclaim memory under the global LRU. It stops deciding what the reader may see: #readingAnchorSequence, #readingAnchorTurnId, #adjacentReadingSequence, #intent, the protected-sequence arguments of #evictToBudget, readAt, #findTurnSequence, #resolveReadingAnchor and the eviction-side flip of #hasOlder / #hasNewer are removed. hasOlder / hasNewer are the Host's page cursors and nothing else.
Tail following is a one-bit subscription the Renderer switches with the pin: while pinned, durable advancement is delivered as pages; while released, only the watermark is delivered and the Renderer renders hasNewer. This replaces intent, preserveRange and readingTurnId in DesktopTranscriptNavigation.
Batches carry pages and the watermark. evictedDurableSequences leaves the contract; reset is sent only on open, reconnect and generation change, never as a side effect of a range command.
2. One reader position
The scroll authority publishes the reading position as { turnId, offsetPx } of the first Turn intersecting the viewport, computed from input provenance as #5057 established. Everything else derives from it: the per-Session bookmark, the restore command after a Session switch, the Prompt Rail's current tick, and the window rule above. firstVisibleTurnId() in use-chat-scroll.ts, the rail's own IntersectionObserver for highlighting, and the Main readAt path are removed.
3. One staleness mechanism
The Renderer controller mints navigationVersion; Main echoes it on every batch; the store accepts a batch iff the version is current. The preload copy, the per-consumer copy in the observer, the replica's #navigationToken, and the object-identity checks in the controller and the history gate go. The history gate itself reduces to "one request in flight per controller, latest wins", which is what #4883 made it and what the version already guarantees.
4. Native anchoring has one policy
Pinned: overflow-anchor: none, the authority writes the tail. Released: overflow-anchor: auto, and every transient row (older/newer gap, pending, notices) carries a single shared overflow-anchor: none rule rather than one per PR. The scrollTop = 1 raise stays until Chromium's zero-offset behavior changes; it is documented as the only manual compensation.
What this removes
Approximate, from the files as they stand on main today:
desktop-transcript-replica.ts: the anchor/intent/protection half of eviction, readAt, #findTurnSequence, #resolveReadingAnchor, #awaitingReadingTurn, the loadAround two-page assembly (fix(desktop): stabilize prompt rail history scrolling #5147's change included). Roughly 300 of 1,000 lines.
runtime-host-session-observer.ts: resetRequested on range commands, navigationVersion / navigationPending per consumer, the readAt dispatch in loadTranscriptAround.
prompt-anchor-rail.tsx: the IntersectionObserver and mountedTurnIds tracking; keeps holdJumpDestination.
Tests: transcript-navigation-race.test.ts (8), most of transcript-navigation-regression.test.ts, the anchor/eviction half of desktop-transcript-range-store.test.ts, and the readAt cases in transcript-reading-position-controller.test.ts are replaced by window-rule and version tests.
Nothing is added to the Host protocol. session.transcript.page, its cursors, the fragment format and the guest projected coverage contract (#4549) are unchanged.
Acceptance
Every scenario below is one that a PR in the list above fixed. They are asserted together, on the same fixture, as the regression line for this change:
Scrolling without paging produces no IPC round trip and no store reset.
Non-goals
No spacer-based virtualizer, height index or estimated geometry: gap rows stay the representation of unloaded history (#4123). No Host protocol change. No change to the Composer, admission, or durable execution semantics. The TUI is unaffected.
Relation to existing work
Closes the Renderer-window item of #2913 §4 as redefined here. Supersedes #5147. Records acceptance for the "older → newer → latest navigation" item of #4677. #4099 chose one scroll writer; this issue chooses one window owner and one reader-position owner to go with it.
Problem
Since #4105 and #4206 landed (2026-08-28 to 08-30), fourteen PRs have changed transcript scrolling: #4414, #4417, #4549, #4560, #4577, #4793 (closed), #4809 (closed), #4831, #4883, #4886, #4911, #5001, #5057, #5147 (open). Each restored one behavior that the two refactors had removed or left undefined, and each did it by adding a mechanism at the seam nearest the symptom. The result on current
main:#readingAnchorSequencein the Main replica (an eviction protection point),transcriptReadingAnchorBySessionRefin the Renderer (a cross-Session bookmark),authority.pinned, DOMscrollTop, the Prompt RailactiveSelection, andfirstVisibleTurnId(). They align only through theturnIdstring. Any layer without aturnId(an overlay-only live Turn) takes a special path:readAtwithanchor === null,#findTurnSequence,isLiveTurn.navigationVersionin the Renderer controller, a copy in preload, a copy per consumer in the Main observer,#navigationTokenin the replica, plus object-identity checks in the controller and the history gate.transcript-navigation-race.test.tsholds eight cases for this alone.transcript-contract.ts), decided in the Main process, with no relation to the viewport. A wide window with short Turns holds less than one screen and pages on the first gesture; a single oversized Turn exceeds the budget and needs an exception (desktop-transcript-replica.ts#evictToBudget). fix(desktop): stabilize prompt rail history scrolling #5147 widens the budget on one side for one entry point (loadAround) and narrows the other side by the same amount.readAt, setsresetRequestedin the observer and resends the whole resident range as a reset snapshot. The marginal cost of scrolling is O(resident range), not O(change).overflow-anchoris declaredautoin CSS, switched tononeby the authority while pinned (fix(ui): preserve tail pin and correct reader scroll assertion #4831), and defeated case by case on transient rows (fix(desktop): stabilize prompt rail history scrolling #5147). Chromium refuses to anchor atscrollTop === 0, souse-chat-scroll.tswritesscrollTop = 1before paging. Three mechanisms guarantee "do not jump" and no one of them can be reasoned about alone.None of this is a defect in any single PR. It is what happens when the pixel-synchronous side (the DOM) and the page-asynchronous side (Host → Main → Renderer) both decide what the reader can scroll into, and neither owns the reader's position.
Why the two refactors left this open
#4105 collapsed the JS writers of
scrollTopto one boolean and handed "keep the reader where they were" to native scroll anchoring, stated as free and already correct. It is neither while the transcript is pinned (anchoring and the pin are opposite instructions, #4269), nor when a transient row or an estimated-height block is the anchor node (#4259, #5147), nor at offset zero. The writer count went down; the browser was promoted to co-writer without a protocol.#4206 moved the resident bound from the Renderer to the Main replica and deleted the virtualizer without a replacement for what it had implied: forward paging (#4886), the reading position across a Session switch (#4414), the rail's DOM markers (#4417), and the bookmark/gate/version machinery that every later PR then rebuilt. Its own ledger records runtime state owners going 3 → 2, not → 1. #2913 §4 still lists the Renderer-side bounded window as open work; #4206 removed the previous attempt at it rather than finishing it.
Current boundary
flowchart LR H[Runtime Host<br/>bounded pages] --> M[Main replica<br/>10-Turn window, eviction,<br/>reading anchor, intent] M -->|reset snapshot per command| R[Renderer store<br/>projection of Main's window] R --> D[DOM<br/>scroll authority + native anchoring] D -->|readAt turnId| MTwo processes decide the window; three places hold the reader; the browser corrects positions no process asked for.
Direction
One owner per fact, and the owner is the layer that can measure the fact.
flowchart LR H[Runtime Host<br/>bounded pages, watermark] --> M[Main<br/>page cache + overlay + global LRU] M -->|pages and watermark, tagged with the<br/>Renderer's navigationVersion| R[Renderer store<br/>the only transcript window] R --> D[DOM<br/>scroll authority] D -->|reader position: turnId + offset| R1. The Renderer owns the window
DesktopTranscriptRangeStorebecomes the only holder of the resident range. It requests pages through the existingloadBefore/loadAfter/loadAroundand evicts by its own rule: keep every Turn that intersects the viewport extended by N screens on each side, subject to a byte ceiling that never evicts a Turn intersecting the viewport. Viewport geometry comes from the scroll authority, which already observes the scroller and its children. Budgets are expressed in screens, not Turn counts.The Main replica keeps what only it can do: assemble Host pages and fragments, project the RuntimeEvent overlay, settle overlay coverage, advance the durable watermark, and reclaim memory under the global LRU. It stops deciding what the reader may see:
#readingAnchorSequence,#readingAnchorTurnId,#adjacentReadingSequence,#intent, the protected-sequence arguments of#evictToBudget,readAt,#findTurnSequence,#resolveReadingAnchorand the eviction-side flip of#hasOlder/#hasNewerare removed.hasOlder/hasNewerare the Host's page cursors and nothing else.Tail following is a one-bit subscription the Renderer switches with the pin: while pinned, durable advancement is delivered as pages; while released, only the watermark is delivered and the Renderer renders
hasNewer. This replacesintent,preserveRangeandreadingTurnIdinDesktopTranscriptNavigation.Batches carry pages and the watermark.
evictedDurableSequencesleaves the contract;resetis sent only on open, reconnect and generation change, never as a side effect of a range command.2. One reader position
The scroll authority publishes the reading position as
{ turnId, offsetPx }of the first Turn intersecting the viewport, computed from input provenance as #5057 established. Everything else derives from it: the per-Session bookmark, the restore command after a Session switch, the Prompt Rail's current tick, and the window rule above.firstVisibleTurnId()inuse-chat-scroll.ts, the rail's own IntersectionObserver for highlighting, and the MainreadAtpath are removed.3. One staleness mechanism
The Renderer controller mints
navigationVersion; Main echoes it on every batch; the store accepts a batch iff the version is current. The preload copy, the per-consumer copy in the observer, the replica's#navigationToken, and the object-identity checks in the controller and the history gate go. The history gate itself reduces to "one request in flight per controller, latest wins", which is what #4883 made it and what the version already guarantees.4. Native anchoring has one policy
Pinned:
overflow-anchor: none, the authority writes the tail. Released:overflow-anchor: auto, and every transient row (older/newer gap, pending, notices) carries a single sharedoverflow-anchor: nonerule rather than one per PR. ThescrollTop = 1raise stays until Chromium's zero-offset behavior changes; it is documented as the only manual compensation.What this removes
Approximate, from the files as they stand on
maintoday:desktop-transcript-replica.ts: the anchor/intent/protection half of eviction,readAt,#findTurnSequence,#resolveReadingAnchor,#awaitingReadingTurn, theloadAroundtwo-page assembly (fix(desktop): stabilize prompt rail history scrolling #5147's change included). Roughly 300 of 1,000 lines.runtime-host-session-observer.ts:resetRequestedon range commands,navigationVersion/navigationPendingper consumer, thereadAtdispatch inloadTranscriptAround.transcript-contract.ts:intent,preserveRange,readingTurnId,evictedDurableSequences,DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS.preload.ts: thenavigationVersioncopy.desktop-transcript-range-store.ts: eviction application and#reseton command; gains the window rule.transcript-reading-position.ts: the WeakMap gate andisLiveTurnspecial case.use-chat-scroll.ts:firstVisibleTurnId,reportReadingAnchorplumbing.prompt-anchor-rail.tsx: the IntersectionObserver andmountedTurnIdstracking; keepsholdJumpDestination.transcript-navigation-race.test.ts(8), most oftranscript-navigation-regression.test.ts, the anchor/eviction half ofdesktop-transcript-range-store.test.ts, and thereadAtcases intranscript-reading-position-controller.test.tsare replaced by window-rule and version tests.Nothing is added to the Host protocol.
session.transcript.page, its cursors, the fragment format and the guestprojectedcoverage contract (#4549) are unchanged.Acceptance
Every scenario below is one that a PR in the list above fixed. They are asserted together, on the same fixture, as the regression line for this change:
Non-goals
No spacer-based virtualizer, height index or estimated geometry: gap rows stay the representation of unloaded history (#4123). No Host protocol change. No change to the Composer, admission, or durable execution semantics. The TUI is unaffected.
Relation to existing work
Closes the Renderer-window item of #2913 §4 as redefined here. Supersedes #5147. Records acceptance for the "older → newer → latest navigation" item of #4677. #4099 chose one scroll writer; this issue chooses one window owner and one reader-position owner to go with it.