Skip to content

refactor(desktop): let the Renderer own the transcript window and the reading position #5163

Description

@Astro-Han

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:

  • "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).
  • overflow-anchor is declared auto in CSS, switched to none by 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 at scrollTop === 0, so use-chat-scroll.ts writes scrollTop = 1 before 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 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.
  • transcript-contract.ts: intent, preserveRange, readingTurnId, evictedDurableSequences, DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS.
  • preload.ts: the navigationVersion copy.
  • desktop-transcript-range-store.ts: eviction application and #reset on command; gains the window rule.
  • transcript-reading-position.ts: the WeakMap gate and isLiveTurn special case.
  • use-chat-scroll.ts: firstVisibleTurnId, reportReadingAnchor plumbing.
  • 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:

  1. Prompt Rail jump to a non-resident Turn, then a large upward gesture and a large downward gesture: no stall, no jump, each step retains visible content (fix(desktop): stabilize prompt rail history scrolling #5147, fix(desktop): land prompt-rail jumps at the top and fence the e2e sends #4577).
  2. Page back to the first Turn, click "return to latest": lands at the tail and stays (fix(desktop): keep "return to latest" at the tail #4883).
  3. Page back, then page forward through the same history in a shared Guest Session (fix(desktop): restore forward scrolling in shared Session history #4886, fix(desktop): honor projected transcript watermarks #4549).
  4. Leave a Session while reading history, let another Session grow, return: same Turn at the same viewport edge (fix(ui): restore session transcript reading position #4414).
  5. Read history while the latest Turn streams: the reader is never pulled to the tail; the tail is followed again only on an explicit return (bug(ui): upward scroll can snap back to the live tail during geometry changes #4269, fix(ui): preserve transcript intent and layout through submission #5057).
  6. An oversized Turn larger than the byte ceiling remains fully readable while it intersects the viewport (fix(runtime-host): preserve complete transcript edge turns #4244, perf(desktop): bound rendering within oversized turns #4259).
  7. Manual scrolling keeps exactly one current Prompt Rail tick (fix(ui): restore prompt rail scroll tracking #4417).
  8. WorkHub Coordination tail navigation converges on a sparse tail (WorkHub Coordination transcript is replayed in full on every open and every reconnect #4647).
  9. Mounted Turns are bounded by screens of viewport, not by history length, at 700 px and 1,400 px viewport heights on the 120-Turn and 640-Turn fixtures; no task or LoAF over 50 ms in the fixed interaction trace (tracking(perf): bound Desktop and Runtime Host work as history grows #4677 budget).
  10. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions