Skip to content

refactor(desktop): give the Renderer the transcript window - #5170

Draft
Astro-Han wants to merge 2 commits into
mainfrom
refactor/transcript-renderer-window
Draft

refactor(desktop): give the Renderer the transcript window#5170
Astro-Han wants to merge 2 commits into
mainfrom
refactor/transcript-renderer-window

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Main owned the presentation window — a resident range with reading anchors, navigation versions, overlay settlement across pages and eviction bookkeeping — while the Renderer kept its own scroll state on top. Two owners of one window meant every reader gesture met Main's range accounting, and the reading position existed in six representations that all had to agree.

Main now keeps a tail cache and answers page requests pass-through. loadBefore / loadAfter return a page without touching the cache, loadAround and loadLatest return a reset snapshot, and catch-up evicts whole Turns from the oldest edge while always keeping the newest Turn. The Renderer owns the window: it extends it by pixel bandwidth, trims it with retain, and re-opens the edge it trimmed as a gap.

This resolves the three contracts raised on #5163:

  1. Completing a visible Turn while unpinned. Main used to broadcast completedOverlayMessageIds, so a window retired an overlay whether or not it installed the durable row replacing it. A window off the tail declines tail growth — so it deleted the overlay and dropped the body in the same batch, and the Turn vanished from the reader's view. The catch-up broadcast also filtered its rows through Main's own tail residency, so a row installed and immediately evicted under budget reached no window at all. Retirement is now the window's own inference (installing a durable row retires the overlay it settles) and the broadcast carries every row the catch-up read.
  2. Host paging cursors vs. Renderer gaps. hasOlder / hasNewer are transport-level cursors on a Host answer. The Renderer combines them with its own coverage: an edge it trimmed to meet its budget becomes a gap again on the spot, so paging reaching an end never implies nothing exists outside the window.
  3. Scope of the staleness mechanism. navigationVersion invalidates navigation only. Session id, replica generation and Host epoch remain independent identity checks on every batch. Invalidation also covers late success, errors, replay and fragmented delivery: an in-flight page for a replaced window has its error swallowed rather than surfaced as the current window's failure, and a new version clears partially received fragments so a late fragment cannot splice into the new window.

The reading position is now one representation: the scroll authority publishes the Turn crossing the top of the scrollport, the prompt rail derives its tick from that instead of running its own IntersectionObserver / MutationObserver / rAF loop, and the controller keeps a bookmark to re-anchor after a replica generation change.

Supersedes #5147, which patched these symptoms at the old window authority.

Closes #5163

Behavior change

  • Opening a Session prefetches history until two viewports sit above the reader.
  • Tail growth marks read while any consumer is open, where it previously waited for the reader to have nothing newer.

Verification

Regression for the vanishing Turn (transcript-overlay-settlement.test.ts, "a window parked off the tail keeps the Turn it is reading when that Turn completes") fails without the fix, on the reader-visible body:

AssertionError: the completed body replaces the overlay instead of both disappearing
  actual:   [ 'B partial' ]
  expected: [ 'B partial and completed answer' ]
apps/desktop — tsc --noEmit on tsconfig.{main,renderer,preload,storybook}.json
  0 errors

apps/desktop — node --test dist/main/__tests__/{transcript-*,desktop-transcript-*,
  runtime-host-session-observer,workhub-coordination-transcript-preload,
  app-shell-session-ui-state,runtime-host-session-execution-ipc-main}.test.js
  174 pass / 0 fail

packages/ui — node --test dist/__tests__/{use-chat-scroll,prompt-anchor-rail,
  transcript-scroll-authority,prompt-rail-reading-position}.test.js
  34 pass / 0 fail

apps/desktop — playwright e2e/transcript-scroll-cost.spec.ts e2e/partial-history-notice.spec.ts
  4 passed (28.3s)

root — npm run format && npm run lint
  clean

Ablation removed one guard this refactor had introduced — a check that kept a mounted Turn a pending reveal command was about to scroll to from being trimmed by the bandwidth pass. Every unit test and both e2e specs still passed without it, so it is gone rather than kept on a race nobody can reproduce.

Not run: the full repository suite, and the cold-restart upward-scroll displacement trace proposed on #5163 — the existing transcript-scroll-cost suite asserts structural cost and reachability, not frame-by-frame reader displacement. That trace is tracked as remaining work below.

Remaining work

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — implementation, test authoring and verification across Main, preload and Renderer, under human review.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Main owned the presentation window: a resident range with reading
anchors, navigation versions, overlay settlement across pages and
eviction bookkeeping, while the Renderer kept its own scroll state on
top. Two owners of one window meant every reader gesture met Main's
range accounting, and the reading position existed in six
representations that had to agree.

Main now keeps a tail cache and answers page requests pass-through.
`loadBefore` / `loadAfter` return a page without touching the cache,
`loadAround` and `loadLatest` return a reset snapshot, and catch-up
evicts whole Turns from the oldest edge while always keeping the newest.
The Renderer owns the window: it extends it by pixel bandwidth, trims it
with `retain`, and re-opens the edge it trimmed as a gap. `hasOlder` /
`hasNewer` are Host page cursors on an answer; the Renderer combines them
with its own coverage, so paging reaching an end never means nothing
exists outside the window. `navigationVersion` invalidates navigation
only — Session id, replica generation and Host epoch stay independent
identity checks.

Overlay settlement follows the same rule. Main used to broadcast
`completedOverlayMessageIds`, so a window retired an overlay whether or
not it installed the durable row that replaced it. A window off the tail
declines tail growth, so it deleted the overlay and dropped the body in
the same batch and the Turn vanished from the reader's view. The catch-up
broadcast also filtered its rows through Main's own tail residency, so a
row installed and immediately evicted under budget never reached any
window at all. Retirement is now the window's own inference — installing
a durable row retires the overlay it settles — and the broadcast carries
every row the catch-up read.

The reading position is one representation: the authority publishes the
Turn crossing the top of the scrollport, the prompt rail derives its tick
from that instead of running its own observers, and the controller keeps
a bookmark to re-anchor after a replica generation change.

Behavior changes: opening a Session prefetches history until two
viewports sit above the reader; tail growth marks read while any consumer
is open, where it previously waited for the reader to have nothing newer.

Supersedes #5147, which patched these symptoms at the old window
authority.

Closes #5163

Generated-by: Claude Code
@Astro-Han
Astro-Han force-pushed the refactor/transcript-renderer-window branch from d171fbf to cd811e1 Compare September 10, 2026 16:42
@github-actions github-actions Bot added the effort/XXL Over 2500 readable lines label Sep 10, 2026
The suite asserted that paging works and stays bounded, and sampled the
mounted count only once the range had settled. Neither says where the
reader ended up while a page was installing, which is the whole of what
#5163 reports. This probe reads every frame, and at each change of the
mounted range compares a Turn present on both sides: with no input
between two frames its document position must not move, so
`Delta top + Delta scrollTop` is zero unless the boundary displaced the
reader.

Measured settled-to-settled rather than across the changing frames: the
range passes through an intermediate commit that mounts far more Turns
than it keeps, and scroll anchoring corrects after layout, so a reading
taken inside the change reports a correction that never reached the
screen.

It fails on this branch. Pure trims land at 18px; every page install
displaces the reader by about 1800px.

Also stops asserting `data-search-highlight` after waiting for the jumped
Turn to mount. That highlight clears itself 2.2s after the command lands,
so the assertion fails whenever loading the page around the Turn takes
longer than the flash - a 3s pass turning into an 18s timeout under load,
reproduced 2 of 6 runs. The jump's landing place is read from the reading
position instead, which does not expire.

Restores the band-check guard removed in the previous commit. A
controlled comparison over 6 runs each puts the flake at 2 of 6 without
it and 3 of 6 with it, so the ablation that removed it rested on a single
passing run and the guard is not what that flake was about.

Generated-by: Claude Code

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review, posted at M4n5ter's request. Parallel deep-code-review and bounded simplify-audit, with independent adjudication. Reviewed bba473c27d9cc87c9cde51d0f68f68727ccd22e7 against merge base fa0ff028e14b96bc14d987f156a2ad79d8b45151; the late update was checked before submission.

Request changes: three Blocking findings and two Important findings are attached inline. Correctness verdict: not acceptable. Design verdict: acceptable — Renderer-owned presentation is a net simplification; these fixes do not require returning window ownership to Main. No additional structural simplification is requested.

Verification: 41 existing targeted tests passed (35 range-store/navigation-race + 6 scroll-hook), bundled from the affected sources. Diagnostic probes reproduced fragmented reconnect, extension-after-trim, and failed-prefetch retry. An additional pending-bookmark eviction probe failed on cd811e18 and passes with the guard restored in this head; that resolved issue is not reported as outstanding. Search cancellation was verified through the production call chain, and the new displacement oracle was checked algebraically. UI probes use synthetic geometry, not a real browser displacement trace. No production changes were made.

The latest required CI test check is red at the Electron E2E budget gate (3 recorded tests versus 4 in the spec). Full repository tests and the new browser trace were not independently run.

中文摘要

已包含提交前刚推送的增量,统一裁决后确认 3 项 Blocking、2 项 Important,逐项附在代码上。单一 Renderer 窗口 owner 的方向成立,没有值得追加的结构性简化;目前实现不能通过 correctness 审查。

41 个现有定向测试通过;重连、裁剪后旧扩页、失败重试三个诊断复现仍失败。已验证新提交修好了待定位书签被裁剪的问题,因此不再将它列为未解决项。另通过生产调用链确认预取取消搜索,并确认新增位移测试的公式不能区分正确与失效的滚动锚定。最新 CI 仍为红色。

Comment on lines +342 to +345
if (batch.reset) {
return batch.navigationVersion === undefined ||
batch.navigationVersion === this.#navigationVersion ||
batch.generation !== this.#liveGeneration;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — admit the whole reconnect snapshot, not only its reset batch.

After loadAround or loadLatest, the Renderer expects navigation N > 0. Registry recovery reopens the consumer, whose navigation version starts at 0. This exception admits the new generation's first batch, but only that batch has reset=true; lines 347–348 reject every continuation carrying version 0. Preload still ACKs those batches, including the final ready batch.

A direct probe against this head — initialize the store, expectNavigation(1), then feed an encoded new-generation/version-0 snapshot containing a 300 KiB message — leaves range().ready === false and the replacement message incomplete. The existing navigation-race tests pass because they do not exercise this combination.

Give the entire recovery snapshot one consistent generation-scoped admission rule, while keeping navigation filtering for command answers. Removing Main's navigation replay should not require restoring Main's presentation authority.

中文

执行过导航后,重连的新 consumer 从版本 0 开始;新 generation 仅首批因 reset 特例获准,续批及 ready 均被旧导航版本过滤,但仍被 ACK。300 KiB 快照定向复现停在 ready=false。恢复快照整条流应使用一致的 generation 接收规则,命令回复仍保留导航版本过滤。

Comment on lines +213 to +214
if (canLoad('up') && above < screen * 2) requestHistory('up');
if (canLoad('down') && below < screen * 2) requestHistory('down');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — automatic prefetch clears an outstanding search before its target arrives.

A global search can open another Session with a target outside its initial tail. If that bootstrap is short (e.g. two 600px Turns in a 600px viewport, with older history), the initial band check automatically requests an older page while the target's remote loadAround is still pending. This goes through loadHistory, whose cancel() unconditionally clears the current search target and cancels restoration. When the remote target finally mounts, the reveal command has already disappeared.

This does not require same-frame React timing or another reader gesture. The new mounted-target guard in this commit does not cover it: prefetch runs before that guard, and this target is not mounted yet.

Background filling must not consume explicit navigation intent. Keep it from cancelling an outstanding search/restoration, or defer that filling until the explicit navigation settles. A focused regression should delay the searched-for page, let the short-tail prefetch run, then deliver the target and assert it is actually revealed.

中文

跨 Session 搜索时,目标页仍在远端加载,短 tail bootstrap 会先自动补页;这经 loadHistory → cancel 清除尚未处理的搜索目标。目标稍后挂载也不再定位,无需假设同一帧内的渲染顺序。新 guard 在预取之后,而且只保护已挂载目标,未封住此路径。后台补页不能消费显式导航意图。

Comment on lines +93 to +97
const anchor = edge === 'older' ? range.oldestSequence : range.newestSequence;
const task = command(false, (value, navigation) =>
edge === 'older'
? value.loadBefore(anchor, maxBytes, navigation)
: value.loadAfter(anchor, maxBytes, navigation),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — reject extensions that no longer join the retained window.

An extension captures this edge anchor, but store.retain() can remove that edge without invalidating the request. Its answer still carries the current navigation version, so accept() installs it unconditionally as a command answer.

A delayed-read probe against the production controller/store starts with rows 10..20, begins loadBefore(10), retains 18..20 while the read is outstanding, then returns row 9. The resulting window is [9, 18, 19, 20]: rows 10..17 are missing internally, but the model only exposes outer-edge gaps. Subsequent loadBefore anchors at 9 and loadAfter at 20, so neither fills the missing interval. This is reachable when a reader moves far enough to trim an edge before its network page returns; a small returned Turn need not trigger another trim.

At admission, require an extension to remain connected to the current window, or invalidate the affected in-flight extension when trimming its edge. Keep the single contiguous-window model instead of introducing internal-gap bookkeeping.

中文

扩页以旧边缘为锚点,retain 裁掉该边缘却不使请求失效。定向复现:10..20 开始向前读,期间保留 18..20,旧页 9 返回后得到 [9,18,19,20]。10..17 成为模型无法表达、两端分页无法补回的内部缺口。应在回复接收时验证仍连接当前窗口,或裁剪相应边缘时让在途扩页失效,无须增加多段窗口模型。

Comment on lines +203 to +205
void Promise.resolve(load?.()).catch(() => undefined).finally(() => {
inFlight[direction] = false;
check();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important — do not immediately retry an unchanged failed prefetch.

The finally clears the in-flight flag and calls check() even after failure. If the geometry and history flag are unchanged, check() immediately issues the same request again. Desktop's loadHistory also catches errors, reports them, and resolves normally, so checking rejection alone would not fix the production path.

With the existing two-Turn fixture and a rejecting loader, a single mount issued 20 consecutive requests without input or range changes; the probe deliberately left request 20 pending to stop the loop. A persistent Host/IPC read failure therefore causes unbounded requests and error reporting.

The messages-change effect already rechecks the band after successful data installation. Remove the unconditional completion-driven retry, or require actual progress before continuing; failures should await a fresh input, range change, or recovery trigger.

中文

失败后 finally 立即 check,相同边缘和 history flag 又触发相同请求。单次挂载定向复现连续请求 20 次,测试主动悬停第 20 次才停止。Desktop 会捕获错误并正常 resolve,因此只判断 reject 不够。成功的数据变化已有重新检查入口;失败应等待新输入、范围变化或恢复事件。

Comment on lines +224 to +228
for (const [turnId, top] of current.tops) {
const wasAt = before.tops.get(turnId);
if (wasAt === undefined) continue;
carried += 1;
const displaced = Math.abs(top - wasAt + scrolled);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important — this oracle measures document movement, not reader displacement.

top - wasAt + scrolled is the change in the Turn's document coordinate. Scroll anchoring is meant to preserve its viewport coordinate while its document coordinate changes.

For example, prepend 100px above a carried Turn with no reader input:

  • Correct anchoring: viewport top is unchanged and scrollTop increases by 100 → this reports 100px.
  • Broken anchoring: viewport top increases by 100 and scrollTop is unchanged → this also reports 100px.

Conversely, an unintended 100px scroll-offset jump gives Δtop=-100, ΔscrollTop=100, so the metric reports 0px when that Turn is compared. Thus the asserted threshold cannot establish the no-displacement behavior promised by this new test.

Compare the surviving reading anchor's viewport offset over a boundary without reader input, or compensate only for known reader input rather than the total scroll-offset change. Calibrate the oracle with a correctly anchored prepend and a deliberately displaced case before treating this as evidence for #5163.

中文

该公式算的是文档坐标变化,不是读者看到的位移。上方插入 100px,正确锚定(top 不变、scrollTop +100)与失效锚定(top +100、scrollTop 不变)都会报 100;反而纯 scrollTop 跳变会被抵消为 0。应测阅读锚点的视口偏移,排除或单独扣除读者输入,并先校准正确/错误锚定两种结果。

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

Labels

effort/XXL Over 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants