Skip to content

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

Merged
Astro-Han merged 35 commits into
mainfrom
refactor/transcript-renderer-window
Sep 11, 2026
Merged

feat(desktop)!: give the Renderer the transcript window#5170
Astro-Han merged 35 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 and trims it with retain.

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. The overlay is now a fact about the tail, not a member of the window: the view shows it only while the window reaches the tail, a durable row retires it on sight whether or not the window keeps the row, 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 is reachable again on the spot, so paging reaching an end never implies nothing exists outside the window.
  3. Scope of the staleness mechanism. Durable sequences advance by a stride, so adjacency cannot be read off the numbers; the only proof that rows are contiguous is the Host read that produced them. Every answer therefore carries its anchor — a page names the edge it was read from (extends), a tail change names the watermark it read forward from (coversFrom), a reset names the navigation it answers — and the window is an immutable value that installs an answer only where that anchor is still its edge. A trim needs no announcement: the anchor no longer matches, and the answer is refused on arrival. Batches assemble off-window and install once at ready, so the screen never shows half an answer and no fill barrier is needed while a jump is in flight. Main's merge drops rows and clears coversFrom when consecutive changes do not meet, so a lost increment degrades to a watermark the window cannot join — it sets hasNewer and the band reads forward from its own edge. Main's navigation number is only a cancellation hint; the system is correct without it.

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.

Automatic filling has its own channel. It had been borrowing the reader's navigation channel, which cancels restoration and clears the search target — right for a reader who decided where to be, wrong for a band that decided nothing — and which swallows errors, so a fill could not tell success from failure. It now reports whether the window moved, and a read that moved nothing (failed, or refused as stale) is not reissued until the reader or the range moves.

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

Closes #5163

The range boundary notice is gone

The 上方还有未加载的较早消息 / 下方还有未加载的较新消息 rows and their Load earlier / Load newer buttons are removed.

They told the reader that the window they are reading does not hold the whole transcript, and gave them a button to extend it. Neither is theirs to care about once the Renderer owns the window: it is a memory budget, the band already fills the edge a reader approaches, and every arrival and departure of the notice is a height change above or below the reader with no content behind it. It arrived in #4560 without a design decision — the merge's own before/after images do not show it — and #5147 had to exclude it from browser scroll anchoring, because a row that is not content was becoming the anchor the reader's position is measured from.

PartialHistoryNotice story, before and after

Light only: the storybook harness here does not switch themes from the CLI, and every rule removed is theme-agnostic — the gap row's width, margin and padding, plus its overflow-anchor: none exclusion.

Removing it retires that exclusion, the row-projection module whose only job was placing the two rows among the Turns, the copy in three locales, and the explicit history-load path they were the only entry point to: loadHistory('earlier' | 'later'), historyLoadPending and the pending plumbing behind it. The one navigation a reader still makes is returning to the tail, now returnToLatest().

It was also hiding a bug. Returning to the tail cancelled an outstanding bookmark frame only because the notice's pending state forced a render; without that render the queued frame scrolled the reader back to the bookmark they had just left. An explicit tail pin now outranks a queued restore, which is what the return-to-latest button consumes a pending bookmark frame always claimed to check — it fails without the fix.

Behavior change

  • Opening a Session prefetches history until two viewports sit above the reader.
  • Tail growth marks read only when the Renderer window acknowledges having reached that watermark with nothing newer; a reader parked in history keeps their unread state, and opening a Session no longer marks it read on its own.
  • A bounded range no longer shows a boundary notice or a control to extend it.
  • The WorkHub transcript is filled and trimmed by the same band as the chat surface; its explicit "older" button is removed.

The other consumers of Main's window

Review found the read marker still reading !change.hasNewer — a fact Main derived from the window it no longer owns. That is an incomplete migration rather than a single missing branch, so every other consumer of the window facts Main used to own was enumerated and checked for whether it still has a source. Five follow-ups:

  • bf3b56ffe — the read marker moves only from an explicit Renderer acknowledgement of the watermark its window reached, and only once that watermark is the replica's own.
  • 5ca912757loadLatest reads the tail back from the Host when global reclaim has emptied Main's cache, instead of replaying an empty snapshot as the whole transcript.
  • 368ac3d38 — the reading position survives a Host epoch change, resolved through the Turn landmark index; a read in flight across that change is superseded rather than surfaced as a load error.
  • b239a9b39 — the WorkHub transcript gets the pixel band (prefetchHistory / retain on its port), so its window is bounded and a queued follow-up returns the window to the tail.
  • 16191d20f — the band re-checks on viewport resize, observing the root's size directly: the authority publishes only on snapshot change, and a resize that keeps the pin and the reading Turn publishes nothing.
  • 0a881c46c — tail acknowledgement is opt-in for a reader that renders the transcript; readDelegatedTurnResult computes a WorkHub preview through the same controller and never renders, yet its first ready() acknowledged the tail and cleared the target Session's unread state (MicroGery's finding). Only the recovering controller, which every visible reader uses and no projection does, opts in.

Two audited items are deliberately unfixed: an unanswered edge read poisoning that edge, for which no reachable path was found with a consumer open, and the rail jump's destination hold, covered by the browser's own anchoring and the page-install displacement probe.

A simplification pass over the PR's own surface followed (ae1a52d71..16dfa837e): a queued page's unread replaces, a navigation field on the replica snapshot the replica never wrote, a controller mirror of the store's navigation counter, a public accepts() that ran the same test twice, the direction argument of subscribeToReaderScroll once the band computed direction from geometry, a per-consumer acknowledgement dedupe in Main that mirrored the Renderer's, WorkHub's second snapshot publication path (it now subscribes to the store's commit), and a duplicated epoch-changed message literal (now one exported code).

A dead-weight pass from review (4bce9ecb4, 4ee559739) removed the one-field DesktopTranscriptNavigation wrapper, four props ChatMessageSurface re-declared and re-forwarded, comments that restated code or pinned numbers nothing re-verifies, e2e assertions against selectors no product code emits, and gave the parked-completion probe the overlay assertion that justifies it. 1c1c3ff1e closes a leak the merge with #4562 exposed: openTranscript rejected a source missing the window contract outside its cleanup path, leaving the registration and its ready promise behind.

Verification

Totoro-qaq's probes (adopted as transcript-pending-jump-probe.test.ts and transcript-parked-completion-probe.test.ts) reproduced the three holes on earlier heads of this branch — [5, 6, 17], a lost jump target, and [5, 6, 21] — and pass on the anchored window. The parked-window settlement test (transcript-overlay-settlement.test.ts) used to assert the third hole as the intended behaviour and is rewritten: after the band trims the newer edge the overlay is no longer shown, the durable range stays what it was trimmed to when the Turn completes, and reading forward from the edge brings the completed body back.

Each fix carries a regression that fails without it, verified by ablation:

Regression Ablated
a fill issued while a jump is pending does not splice the old edge onto the new window older-edge anchor check dropped — row 17 splices onto [5, 6]
a page anchored on an edge the band has since dropped is refused older-edge anchor check dropped — row 9 splices onto [18, 19, 20]
a Turn completing at the tail does not splice into a window parked far from it coversFrom check dropped — row 21 splices onto [5, 6]
tail rows a window cannot join are dropped, and its mismatched watermark still moves coversFrom check dropped — row 9 splices onto [1]
a fill is issued once per window and again as soon as the window moves spent not compared to the window — a second identical read
a fill that left an edge where it found it is not asked again spent not compared to the window — a second identical read
a failed fill is not reissued until the reader moves again unconditional re-check — fails
filling an edge leaves an outstanding jump alone fill routed through the navigation channel — clears the search target
the return-to-latest button consumes a pending bookmark frame pin not outranking a queued restore — reader scrolled back to the bookmark
moves the read marker only as far as the Renderer window reports reaching acknowledgement gate dropped — a parked reader's completing Turn is marked read
reports each tail the window reaches once, and none while it is parked acknowledgement gate dropped — the window reports a tail it refused
a viewport that grows fills the band it just widened, without a reader gesture resize observation dropped — no fill until the next reader gesture
a viewport that shrinks trims what it just pushed beyond the band resize observation dropped — the range stays retained beyond the band
delegation feedback does not advance the target Session read marker acknowledgement unconditional — the projection acknowledges row 2
releases a transcript registration whose source lacks the window contract source check outside the cleanup path — session-1 stays registered
apps/desktop — tsc --noEmit on tsconfig.{main,renderer,preload,storybook}.json
  0 errors

apps/desktop — npm run test:dist
  2491 pass / 0 fail

packages/ui — npm run test:dist
  418 pass / 0 fail

apps/desktop — playwright e2e/transcript-scroll-cost.spec.ts
  e2e/partial-history-notice.spec.ts --repeat-each=2
  10 passed
apps/desktop — playwright e2e/streaming-remount.spec.ts e2e/quote-window-boundary.spec.ts
  5 passed

apps/desktop — npm run build-storybook
  completed

root — npm run format && npm run lint
  clean

partial-history-notice.spec.ts was flaking at 3/6 when this PR opened. Both of its failure points turned out to be real product behaviour rather than test fragility: a data-search-highlight assertion racing that highlight's own 2.2s expiry, and — after the trim fix — a refused answer being reissued from the fill's own callback, which turned the spec from 3.4s into a 1.3m fetch loop. It now runs green under repeats.

Ablation removed one guard this refactor had introduced. The band-check pending guard was put back: a controlled run showed 2/6 failures without it against 3/6 with it, which does not clear it, and the original ablation had rested on a single passing run.

Verification not run

The full repository suite.

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 thread apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts Outdated
Comment thread packages/ui/src/use-chat-scroll.ts
Comment thread apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts Outdated
Comment thread packages/ui/src/use-chat-scroll.ts Outdated
Comment thread apps/desktop/e2e/transcript-scroll-cost.spec.ts Outdated
…assumed

The window has five writers — command answers, replica replacements, tail
broadcasts, band trims and automatic fills — but only command answers were
invalidatable, and the automatic fill borrowed the reader's own navigation
channel. Five reported defects fall out of those two gaps.

A read is answerable only while what it assumed still holds, and the two kinds
of read assume different things. An extension splices rows onto one edge, so
any replacement of that edge — navigating away, or the band trimming it out —
makes its answer unable to reach what is left; installing it would open a hole
the contiguous-window model cannot express and no edge cursor can name. A
replacement discards the edges, so only a newer navigation makes it stale, and
a replica replacement that answers nothing this window asked for is admitted
whole rather than misfiltered as a superseded command.

Filling an edge is not navigating to it: it gets its own channel, so it no
longer consumes the reader's outstanding jump, and it answers whether the
window moved so a read that changed nothing is not reissued in its own
callback, forever.

Generated-by: Claude Code
@Astro-Han

Copy link
Copy Markdown
Contributor Author

Thanks @M4n5ter — all five are fixed in aedc843, and each thread has the regression that fails without its fix. Rather than patch them one at a time I looked for what they share, and they collapse into two.

The window had five writers and one of them could be invalidated. Command answers, replica replacements, tail broadcasts, band trims and automatic fills all write the window, but the only invalidation mechanism was a command version. So a replacement was misfiltered as a stale command answer (#1), and a trim bypassed invalidation entirely, letting an extension splice rows onto an edge that no longer existed (#3) — your [9, 18, 19, 20], a hole the contiguous-window model cannot even name.

The rule that covers all five writers is what each read assumed. An extension assumes one edge, so any replacement of that edge refuses it. A replacement assumes nothing about the edges, because it discards them, so only a newer navigation makes it stale. Batches that name no epoch answer nothing and apply to whatever the window holds. Promoting the counter this way removed a special case rather than adding one — the old batch.generation !== this.#liveGeneration branch is gone.

Automatic filling had borrowed the reader's navigation channel. loadHistory cancels restoration and clears the search target, which is right for a reader who decided where to be and wrong for a band that decided nothing (#2), and it swallows errors so failure could not be told from success (#4). It has its own command now, and it reports whether the window moved: a read that moved nothing — failed, or refused as stale — is not reissued until the reader or the range moves. That second half turned out to matter more than expected, because refusing an answer is exactly a read that moved nothing: without it, fixing #3 turned partial-history-notice.spec.ts from 3.4s into a 1.3m fetch loop.

On your oracle comment (#5) — you were right, and that formula had already misled me once before you got here. Details and the limits of what the probe can prove are in that thread.

Verified: 2426 apps/desktop tests, 410 @maka/ui tests, all four tsconfigs, Biome format and lint, and partial-history-notice.spec.ts + transcript-scroll-cost.spec.ts at 3 repeats each (12/12). partial-history-notice.spec.ts was the P0 flake you flagged: it is stable now, and its two remaining failure points both turned out to be real product behaviour rather than test fragility.

@Totoro-qaq

Totoro-qaq commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

@Astro-Han Thanks for pushing this through. Separating what an extension assumes from what a replacement assumes reads much clearer in aedc843, and the Renderer-owned window does look like the simpler model.

I ran a few probes against aedc843 (the real observer, replica and Renderer store, with a fake Host), and three sequences still seem to leave the window somewhere the contiguous model can't recover from. I may be missing a guard, so please read these as questions.

A fill during a pending jump can splice the old edge onto the new window. replaceWindow() mints the epoch when loadAround is called, but range() still returns the old window until the reset lands, so an extend() in that gap pairs the old anchor with the new epoch. The replica's #enqueue then answers the jump first and the fill right after it. Probe: tail 18..20, jump to 5, fill before the reset lands, result [5, 6, 17] with newest 17, so 7..16 can't be paged in. This looks like the search ordering from the earlier review, now that the fill no longer cancels the jump.

A trim plus a fill during a pending jump makes Main drop the jump. retain() mints only a window epoch, but Main keeps a single consumer.windowEpoch, so the fill's higher epoch makes the in-flight loadAround fail isCurrent(). Probe: same setup, retain(19, 20) and then a fill. Both commands resolve without an error and the window stays at [18, 19, 20].

A Turn completing at the tail lands in a window parked far from it. loadAround's snapshot carries the live overlay, so a jump during a run shows [5, 6] plus overlay 21. When 21 completes it is inserted as durable, giving [5, 6, 21] with newest 21, and 7..20 are inside the range with no edge left to fetch them. The new settlement test checks Turn B's text but not its neighbours.

In case it's useful, a couple of directions I could imagine, though you'll know better what fits: hold extensions until the outstanding navigation's reset commits (or mint a window epoch when it does) and keep navigation and window epochs separate in Main; for a parked window, update the overlay in place and move it into the durable range only once the window reaches it.

Happy to share the probe files if that would help.

The notice told the reader that the window they are reading does not hold the
whole transcript, and gave them a button to extend it. Neither is theirs to
care about: the window is a memory budget, the band already fills the edge a
reader approaches, and every arrival and departure of the notice is a height
change above or below them with no content behind it.

It arrived in #4560 without a design decision — the merge's own before/after
images do not show it — and #5147 has since had to exclude it from browser
scroll anchoring, because a row that is not content was becoming the anchor the
reader's position is measured from. Removing it retires that exclusion, the
row-projection module whose only job was placing it, its copy in three locales,
and the whole explicit history-load path it was the only entry point to: the
one navigation a reader still makes is returning to the tail.

Removing it also exposed a bug it had been hiding. Returning to the tail
cancelled an outstanding bookmark frame only because the notice's pending state
forced a render; without that render the queued frame scrolled the reader back
to the bookmark they had just left. An explicit pin now outranks a queued
restore, which is what the existing regression always claimed to check.

Generated-by: Claude Code
@Astro-Han Astro-Han changed the title refactor(desktop): give the Renderer the transcript window feat(desktop)!: give the Renderer the transcript window Sep 11, 2026
…k inventory

Removing the range boundary notice removed the `useState` that held its pending
target; the gate counts call sites, so the inventory has to say 11.

Generated-by: Claude Code
The removals in this branch move every recorded number down, except
`app-shell-effects.ts`, which grew with the reading-position work.

The retain callback's parameter was also named `window`, which shadows the
global and which the scanner reads as an environment capability the shell does
not use. Naming it for what it is keeps a phantom capability out of the ledger.

Generated-by: Claude Code
…ipt apply is live

`applyTranscript` and `applyReadError` each took an `isDisposed` closure so a
stable event handler could re-check a flag its caller already knew. Both callers
know it locally: the store subscription is torn down in the effect's cleanup, so
anything it publishes is live by construction, and the two error paths either
sit behind the batch callback's own early return or check the flag where they
are.

Removing the indirection takes the file below the debt the ledger recorded for
it before this branch, so the architecture ratchet passes again.

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 targeted re-review at M4n5ter's request, against 327d96a89b43a8b77297eadecdefb3afe4f91c20. Ordinary review comments only.

The previous direct failures have been addressed: unsolicited recovery snapshots are unversioned throughout; trimmed-edge answers are refused; background filling no longer calls the explicit cancellation path; failed/no-progress filling stops reissuing itself; and the displacement oracle now uses viewport coordinates. The restored pending-target guard remains in place.

One correctness issue remains, attached inline: the new distinction between navigation lifetime and edge lifetime is not maintained through Main or all fragments of a replacement response. Two focused probes reproduce both paths. Not approving this revision yet. The Renderer-owned window direction remains acceptable; no additional independent simplification is requested.

Validation: 47 existing targeted tests passed (range store, navigation races, scroll hook, return-to-latest pin); 2 added diagnostic probes failed as described inline. Production source was unchanged. No full-repository suite or real-browser displacement run was performed independently. The latest CI failure is the stale generated docs/astryx-surface-file-inventory.md, separate from this runtime issue.

中文

上轮直接问题已逐项修复;本次剩 1 个 correctness 问题:导航与边缘维护虽然分出了不同生命周期,但 Main 和分批回复仍混用,已用两个定向测试复现,因此暂不 approve。

47 个现有定向测试通过,2 个诊断复现失败。单一 Renderer 窗口 owner 的方向成立,没有额外独立简化建议。CI 另因生成清单未更新失败;未独立运行完整仓库测试或真实浏览器位移测试。

Comment thread apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts Outdated
@M4n5ter
M4n5ter dismissed their stale review September 11, 2026 01:00

At M4n5ter’s direction, withdraw the blocking review state and retain the review comments. The current follow-up is a COMMENT review; this is not an approval.

…w under it

The window has two lifetimes, and the split stopped at the Renderer's
`accepts()`. Both leaks let a navigation the reader asked for be dropped.

A replacement admitted its reset batch under the navigation epoch and then
failed its own continuation fragments against the window epoch a trim had
minted meanwhile. The reset now takes the window's identity back to the epoch
the navigation was issued under — it discards the edges those trims were
protecting — so the rest of its fragments splice on. Minting moves to a counter
kept apart from that identity, so a number still names one window forever and a
page in flight under the trimmed one stays refusable.

Main ordered by a single epoch and read any newer number as "the Renderer left
that window", which an extension issued after a trim is not: it advanced the
consumer and silently discarded the outstanding replacement, even a one-batch
one. Only a replacing command now moves the consumer; an extension naming a
newer window is admitted without moving it, and a read stays current while its
epoch is at least the standing replacement's.

Also refreshes the astryx surface inventory, stale since chat-view stopped
reaching for HStack and Text with the range boundary notice.

Generated-by: Claude Code
Letting an extension name a newer window without moving the consumer left the
queued answers of the window it left behind in place: they held their bytes in
the delivery budget until they were sent and refused, and the opening ramp
mints an epoch per trim. A consumer could reach its delivery capacity on a
transcript nothing was wrong with.

Queued answers are dropped again, now by what the window can still take rather
than by whichever number is newest: an extension splices onto the window it
named, so any newer one strands it; a replacement installs its own window, so
only a newer replacement does. The same question decides what the delivery loop
sends, so a page cannot be kept and then discarded unsent.

Sizes the scroll suite's settle wait against the opening ramp rather than the
10s default, which the suite documents as sized for UI already on screen. How
many pages the ramp reads is how tall the viewport happens to be against the
fixture; this machine finishes it in under two seconds and a loaded CI runner
measured past ten.

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 targeted re-review at M4n5ter's request, against 5b8679e2978687207a8d90a67742904bdf0c2dff.

The previous two failure paths are fixed: a fragmented replacement survives trimming, and background filling no longer cancels that replacement. One follow-on correctness regression remains in the new delivery-budget cleanup: after that replacement succeeds, fresh pagination can be silently discarded. See the inline comment; not approving this revision yet.

Validation: 49 existing targeted tests passed. An additional probe using the actual range controller, observer, encoder and store confirms the replacement completes, but then fails when loading its next page. No production code was changed. The Renderer-owned window direction remains acceptable; no separate simplification is requested.

The latest CI test check also fails the new scroll-boundary E2E assertion (reported displacement 120px versus the 40px limit; 37 other E2E tests passed). I have not established whether that failure is product motion or measurement/input timing, and am not classifying it as a flake. Full repository and browser suites were not independently rerun.

中文

上次两个失败路径已修复,49 个现有定向测试通过。但新增预算清理逻辑会在导航成功后丢弃新的有效分页;实际 controller/observer/store 串联复现失败,因此暂不 approve。

CI 另在滚动边界 E2E 中报告 120px 位移、超过 40px 阈值;尚未确定是产品位移还是测量/输入时序问题,不能直接视为 flaky。单一 Renderer 窗口方向仍可接受,无独立简化建议。

Comment thread apps/desktop/src/main/runtime-host-session-observer.ts Outdated
…ants

Three rounds of epoch patches had left the window with no statable rule about
when a read may be spliced onto it. Adversarial review found two ways to break
it, both of them mine, and both dissolve once the lifecycle is named.

The window is in one of two states. Settled: its rows are the window its
identity names, so an edge can be read from and its answer spliced back.
Navigating: a replacement has claimed the window but its rows have not arrived,
so the edges on screen belong to the window being left.

I1, the window is always contiguous. A read is answerable only while the edge
it was anchored on is still the window's edge. An extension may therefore only
be issued while settled, and carries the window's identity; a replacement
carries its own, and all of its fragments belong to it however many epochs the
band mints underneath. Installing a replacement names the window afresh, which
refuses every read that was anchored on an edge it threw away. Filling while
navigating anchored on the window being left and spliced onto the one that
replaced it — a hole no edge cursor can name and no later page can fill. Now
there is nothing to anchor on, so nothing is asked.

I2, a fill does not spin. A read that left an edge exactly where it found it
answers the same way again, so the range keeps where each edge stood and
refuses to re-ask until something moves it. This replaces the `moved` answer
`prefetchHistory` reported back to the scroll hook, which asked the geometry to
decide whether a read was worth issuing: it read the store's snapshot identity,
which a Session streaming into its tail changes on its own, so the guard was
blind exactly when it was needed. Deciding whether a read is worth issuing
belongs to the layer holding the window.

Main keeps a single number again. Which window the Renderer holds is not
derivable from the epochs Main sees and does not need to be — the Renderer
refuses what it cannot splice. The monotone `newestWindowEpoch` of the previous
commit claimed to derive it and got it wrong: once a navigation's answer landed
it stranded every later fill, leaving the older edge unable to load for the rest
of the Session.

Also drops the gap-row assertion the perf suite still made against a row deleted
with the range boundary notice, and the two dead gap measurements beside it.

Generated-by: Claude Code
…on the wheel

The probe skipped frames within 250ms of the last wheel event, on the theory
that a reading which catches a tick cannot tell the reader's own scrolling from
a page that moved them. The gesture and the rAF that reads it land in the same
frame in an order nothing here controls, so on a loaded runner the reading went
first and the guard was still holding the previous gesture's timestamp: eight
boundaries reported displacement of exactly 120px, one wheel tick, where this
machine reported none.

A time window cannot fix that, because the quantity it is inferring — whether
the reader is mid-gesture — is something the test already knows. It now says
so: the measurement window closes around each gesture and opens for the quiet
that follows, which is where a page lands and where a reader would see it jump.

Generated-by: Claude Code
Throwaway drivers for investigating a scenario by hand have to sit beside the
suite to resolve `@playwright/test`, and four of them reached a commit here,
where the ASF header audit correctly refused them.

Generated-by: Claude Code
@Astro-Han

Copy link
Copy Markdown
Contributor Author

@Totoro-qaq Hi, the probe files would be indeed very useful! It would be great if you could share it on this PR discussion.

Two stories still answered `onPrefetchHistory` with whether the window moved,
which the range controller now decides for itself.

Generated-by: Claude Code
@Totoro-qaq

Copy link
Copy Markdown
Contributor

@Astro-Han Thanks, here they are. I re-ran them on 15894f8: the first two now pass, and your new regression tests look like they cover the same sequences. The third still reproduces.

Both files follow the layout of transcript-navigation-race.test.ts, so they should drop straight into apps/desktop/src/main/__tests__/.

On 15894f8:

not ok 1 - a Turn completing at the tail does not splice into a window parked far from it
    + actual - expected
    +   21
ok 2 - a fill issued while a jump is pending does not splice the old edge onto the new window
ok 3 - a trim and a fill while a jump is pending do not make Main drop the jump

On aedc843 the two jump tests failed as described earlier: the window ended at [5, 6, 17], and the jump target never arrived.

For the third one I wasn't sure what the intended behaviour is, since the settlement test wants the completed body to stay visible. So the assertion only checks that the durable range stays [5, 6]; it doesn't prescribe how the Turn remains on screen.

transcript-pending-jump-probe.test.ts (jump + fill, jump + trim + fill)
/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import assert from 'node:assert/strict';
import test from 'node:test';
import type { StoredMessage } from '@maka/core/session';
import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage } from '@maka/runtime-host/protocol';
import type { DesktopTranscriptBatch, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js';
import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js';
import { RuntimeHostSessionObserver } from '../runtime-host-session-observer.js';
import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js';

// Drives the real observer, replica and Renderer store with a fake Host whose
// jump read (loadAround) is held until a fill has been queued behind it.
const PAGE_BYTES = 128 * 1024;
const THROUGH = 20;

async function harness() {
  const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1']));
  const eventsClosed = deferred<void>();
  const aroundEntered = deferred<void>();
  const releaseAround = deferred<void>();
  const bootstrap = page();
  const decoded = new Map<SessionTranscriptPage, { messages: Array<ReturnType<typeof record>>; nextCursor: string | null }>([
    [bootstrap, { messages: [record(18), record(19), record(20)], nextCursor: 'older' }],
  ]);
  const observer = new RuntimeHostSessionObserver({
    client: { openSession: async () => runtimeHostSessionFixture({
      snapshot: continuitySnapshot(), transcript: Promise.resolve([]),
      events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } },
      transcriptBootstrap: { throughSequence: THROUGH, overlayMessageCount: 0, durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' } },
      loadTranscriptOverlay: async () => [],
      decodeTranscriptPage: async (candidate) => decoded.get(candidate)!,
      loadTranscriptPage: async (request) => {
        const candidate = page();
        if (request.direction === 'newer') {
          aroundEntered.resolve();
          await releaseAround.promise;
          decoded.set(candidate, { messages: [record(5), record(6)], nextCursor: 'newer' });
        } else if (request.maxBytes === 1) {
          decoded.set(candidate, { messages: [record(4)], nextCursor: 'older' });
        } else {
          decoded.set(candidate, { messages: [record(request.anchorSequence! - 1)], nextCursor: 'older' });
        }
        return candidate;
      },
      async close() { eventsClosed.resolve(); },
    }) },
    emitSessionsChanged() {},
  });
  const ack = (batch: DesktopTranscriptBatch) => observer.acknowledgeTranscript('consumer-1', batch.generation, batch.deliverySequence, 1);
  await observer.openTranscript('session-1', 'consumer-1', {
    id: 1, once() {}, off() {},
    send(_channel, batch) { store.accept(batch); queueMicrotask(() => ack(batch)); },
  });
  const request = (windowEpoch: number, anchorSequence: number): DesktopTranscriptRangeRequest => ({
    consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', anchorSequence, maxBytes: PAGE_BYTES, windowEpoch,
  });
  const sequences = () => store.durableEntries().map(({ sequence }) => sequence);
  return { store, observer, request, sequences, aroundEntered, releaseAround };
}

test('a fill issued while a jump is pending does not splice the old edge onto the new window', async () => {
  const h = await harness();
  try {
    assert.deepEqual(h.sequences(), [18, 19, 20]);
    const navigation = h.store.replaceWindow();
    const jump = h.observer.loadTranscriptAround(h.request(navigation, 5), 1);
    await h.aroundEntered.promise;
    // Anchored on the window still on screen (18), stamped with the jump's epoch.
    const fill = h.observer.loadTranscriptBefore(h.request(h.store.windowEpoch(), h.store.range().oldestSequence!), 1);
    h.releaseAround.resolve();
    await Promise.all([jump, fill]);
    assert.deepEqual(h.sequences(), [5, 6]);
  } finally {
    await h.observer.close();
  }
});

test('a trim and a fill while a jump is pending do not make Main drop the jump', async () => {
  const h = await harness();
  try {
    const navigation = h.store.replaceWindow();
    const jump = h.observer.loadTranscriptAround(h.request(navigation, 5), 1);
    await h.aroundEntered.promise;
    assert.equal(h.store.retain(19, 20), true);
    const fill = h.observer.loadTranscriptBefore(h.request(h.store.windowEpoch(), h.store.range().oldestSequence!), 1);
    h.releaseAround.resolve();
    await Promise.all([jump, fill]);
    assert.ok(h.sequences().includes(5), `the jump target never arrived: ${JSON.stringify(h.sequences())}`);
  } finally {
    await h.observer.close();
  }
});

function record(identity: number) {
  const message: StoredMessage = { type: 'assistant', id: `message-${identity}`, turnId: `turn-${identity}`, ts: 1, text: String(identity), modelId: 'test' };
  return { identity, message };
}
function page(): SessionTranscriptPage {
  return { kind: 'page', sessionId: 'session-1', source: 'durable', direction: 'older', throughSequence: THROUGH,
    rawBytes: 1, fragments: [], rangeBoundarySequence: null, protectedTurnSequence: null, nextCursor: null };
}
function continuitySnapshot() {
  return { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION,
    session: { sessionId: 'session-1', metadataRevision: 1, status: 'running' as const, createdAt: 1, isArchived: false },
    projectionRevision: 1, rootTurn: null, goal: null,
    queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] } };
}
function deferred<T>() {
  let resolve!: (value: T | PromiseLike<T>) => void;
  const promise = new Promise<T>((complete) => { resolve = complete; });
  return { promise, resolve };
}
transcript-parked-completion-probe.test.ts (a completion landing in a parked window)
/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import assert from 'node:assert/strict';
import test from 'node:test';
import type { StoredMessage } from '@maka/core/session';
import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js';
import { encodeDesktopTranscriptChange, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js';

const identity = { sessionId: 'session-1', hostEpoch: 'host-1', generation: 'generation-1' };
const message = (sequence: number, text = String(sequence)): StoredMessage =>
  ({ type: 'assistant', id: `message-${sequence}`, turnId: `turn-${sequence}`, ts: 1, text, modelId: 'test' });

test('a Turn completing at the tail does not splice into a window parked far from it', () => {
  const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1']));
  // A jump to 5 during a run: loadAround's snapshot carries the live overlay (21).
  const navigation = store.replaceWindow();
  for (const batch of encodeDesktopTranscriptSnapshot({
    ...identity, windowEpoch: navigation, durableThrough: 20,
    durable: [{ sequence: 5, message: message(5) }, { sequence: 6, message: message(6) }],
    overlay: [message(21, 'partial')], hasOlder: true, hasNewer: true,
  })) store.accept(batch);
  // 21 completes; the tail broadcast carries its durable row.
  for (const batch of encodeDesktopTranscriptChange(identity, {
    durableThrough: 21, durableUpserts: [{ sequence: 21, message: message(21, 'partial and completed') }],
  })) store.accept(batch);
  // 7..20 are not in the window, so 21 cannot join its durable range contiguously.
  assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [5, 6]);
});

The probes were written with Claude Code; I ran them on both heads.

@orangeCatDeveloper

Copy link
Copy Markdown
Contributor

A suggestion on the overlay handling. I don't think this is a missing guard — a durable row shouldn't go on the axis at all while the window is parked.

#acceptFragment computes installDurable = answersCommand || !this.#hasNewer (L448), then at L662 admits the row anyway when overlaid is true. With the window parked far from the tail that installs it at its own sequence and drags newestSequence with it — Totoro-qaq's third probe, still red on 15894f8: durable ends up [5, 6, 21], and 7–20 sit inside [oldest, newest] with no edge left to reach them.

The retirement rule is right; it's the install half that shouldn't run here. The body is still useful — it's the settled text of a message this window is showing, so it can refresh the overlay record in place without ever taking a sequence. Element does exactly this on remote echo (same object mutated; pendingEventList stays outside the timeline until !canPaginate(FORWARDS)), and Codex keeps the in-flight message in active_cell rather than transcript_cells.

       const overlaid = this.#overlay.has(message.id);
       if (!installDurable && !overlaid) return false;
       const sequence = pending.identity as number;
+      if (!installDurable) {
+        // Settled text for a message this window is showing, but the window
+        // does not reach the row's place on the axis. Refresh the overlay in
+        // place; installing it would open a hole no edge cursor can name.
+        const settled = this.#overlay.get(message.id)!;
+        this.#overlay.set(message.id, { message, encoded: projected, order: settled.order });
+        this.#declinedSequence = Math.max(this.#declinedSequence ?? sequence, sequence);
+        return true;
+      }
       const existing = this.#durable.get(sequence);

#overlayOrder doesn't move and #createSnapshot already appends overlay after durable, so the reader keeps the message with its final text. Once the window reaches the tail, installDurable is true and the existing install-and-retire path runs unchanged — still what a window parked off the tail keeps the Turn it is reading when that Turn completes asserts.

Two loose ends: #declinedSequence clears in #reset() (a replacement window inherits nothing), and a loadAfter answer shouldn't set hasNewer = false while it sits above newestSequence, or the edge closes over the row that was just declined — Zulip does the same with _expected_max_message_id. I'd avoid deriving hasNewer from durableThrough instead: the watermark can sit beyond the last visible row (the RuntimeEvent gap the replica comments mention), so it would stick true and spin.

Probe 3 from the thread works as the regression as-is.

…value

The Renderer owned the window but kept Main's mutable structure without
the serial queue that had made it consistent. Four writers — jump, fill,
trim and tail broadcast — mutated it in place across processes and rAF
frames, and a monotone window epoch stood in for the transaction the
structure never had. Three holes followed from that shape, each found
independently by review and by Totoro-qaq's probes: a tail change lost
in Main's delivery loop left the window believing it still reached the
tail, so the next row spliced onto a gap; `#reset()` forgot to mint and
left `#navigating` set; and a jump snapshot carried the whole overlay
into a parked window, whose settlement then installed the durable row
far from anything it held.

Durable sequences advance by a stride, so adjacency cannot be read off
the numbers. The only proof that rows are contiguous is the Host read
that produced them, anchored on a known row or watermark. The window is
now an immutable value and every answer carries its anchor: a page names
the edge it was read from (`extends`), a tail change names the watermark
it read forward from (`coversFrom`), and a reset names the navigation it
answers. Rows land only where the anchor is still the window's edge;
watermark and overlay retirement land regardless, because they are facts
about the tail, not the window. Batches assemble off-window and install
once at `ready`, so the screen never shows half an answer and no
navigating phase or fill barrier is needed.

`coversFrom` has three states: a number or `null` (read from the start
of the transcript) is a contiguity claim; absent is none. Main's merge
drops rows and clears the claim when consecutive changes do not meet,
and the delivery loop no longer cuts an answer short mid-stream. A
dropped increment therefore degrades to a watermark the window cannot
join, which sets `hasNewer` and lets the band read forward from its own
edge — Main's navigation number is now only the cancellation hint the
old comment claimed it was.

The overlay leaves the window. It is shown only while the window reaches
the tail, which retires both entrances the escape hatch had: a jump
snapshot's overlay in a parked window, and a trim that opened the newer
edge without dropping it. The parked-window settlement test encoded that
hatch and is rewritten to the new contract; Totoro-qaq's probes are
adopted as regressions.

Fills report whether they issued a read, and the scroll hook re-checks
only when one did: the previous "cannot spin" claim was false, since a
refused fill resolved synchronously and re-entered the check.

Ablation considered and rejected: pull-only tail delivery (watermark
without rows) would flip `hasNewer` on every Turn completion and hide the
streaming overlay for a round trip each time. The push stays, verified.

Generated-by: Claude Code
`app-shell.tsx` gained one token: the prefetch fallback now resolves to
`false` so the scroll hook knows no read was issued.

Generated-by: Claude Code
@Astro-Han

Copy link
Copy Markdown
Contributor Author

@Totoro-qaq Both probe files are adopted as-is in 32c19749c (transcript-pending-jump-probe.test.ts, transcript-parked-completion-probe.test.ts); the only edits are the API renames (replaceWindow()navigate(), windowEpoch()pendingNavigation()). All three pass, and the two jump probes fail again if the older-edge anchor check is removed, so they are doing real work.

On the third one — you were right to leave the intended behaviour open, because the settlement test was asserting the hole. The rule is now: the overlay is a fact about the tail, not a member of the window, so a window that no longer reaches the tail does not show it; when the Turn completes, the durable range stays whatever the band trimmed it to ([5, 6] in your probe); reading forward from the window's own edge brings the completed body back. transcript-overlay-settlement.test.ts is rewritten to that contract.

The underlying change: every answer now carries the anchor it was read from, and the window installs it only where that anchor is still its edge. The PR body's "Scope of the staleness mechanism" describes it.

`loadLatest` answered from Main's tail cache. Global memory reclaim can
trim that cache to nothing while the Session stays open, so returning
to the latest messages could install an empty window with `hasNewer`
false — a blank conversation, and no affordance left, until the band's
older prefetch refilled it. On `main` the follow-tail command read a
fresh page from the Host.

The tail is read back before it is replayed: when the cache holds fewer
Turns than it should and older rows exist, `loadTranscriptLatest` reads
the newest page from the Host into the cache, then answers through the
existing reset path with the watermark the cache already names. A cache
that covers the whole transcript is not short, so a small Session still
answers without a read. Reclaim keeps trimming; it is a performance
event again, not a correctness one.

Generated-by: Claude Code
Sequences name the same rows only within one Host epoch, so the
generation-change re-anchor stopped at an epoch change and a reader
parked in history was dropped at the tail when the Runtime Host
restarted. On `main` the registry rewrote the outstanding navigation by
Turn id. The Renderer now does the equivalent with what it already has:
after an epoch change it refreshes the Turn landmark index, resolves the
bookmarked Turn id to its new sequence and navigates there; a Turn the
new index does not know leaves the reader at the tail, quietly.

A read still in flight across that change was rejected by Main as a
stale epoch, and the rejection reached the conversation as a load error
for a condition the reset that follows resolves by itself. The
recovering controller now raises that rejection as a superseded read;
the reading-position layer treats it as neither an error nor an
unavailable bookmark, so the banner does not appear and the bookmark
survives for the re-anchor above. Every other error is still raised.

Generated-by: Claude Code
WorkHub mounts the same ChatView as the chat surface but was wired
without the band: no automatic fill, no trim, an explicit "older"
button instead. Two consequences of the Renderer-owned window followed.
Every page the button loaded was retained forever, where Main used to
bound every consumer's window. And a window behind the tail never
followed it — Main used to splice tail growth into whatever window it
held — so a queued follow-up's own row never arrived, the observation
check never satisfied, and the next follow-up was refused as a retry.

The WorkHub transcript port now carries `prefetchHistory` and `retain`,
delegated to the same range controller the chat surface uses, and the
ChatView gets the band's callbacks. The "older" button goes with it.
Queuing a follow-up returns the window to the tail before admission, on
every admission outcome, so the queued row lands and the guard clears.
A trim publishes the trimmed snapshot: it is the one window change with
no batch behind it.

Generated-by: Claude Code
@orangeCatDeveloper

Copy link
Copy Markdown
Contributor

Not a correctness review — the three blocking findings above are the ones that matter, and I have nothing to add to them. This is only a pass for dead weight, checked against b239a9b39. The PR nets about -985 lines of code and +177 lines of comment, and nearly every comment item below is a line this PR adds.

1. An orphaned doc comment that is now wrong

apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts:1281

/** The one-byte read `loadAround` uses to ask whether `sequence` has anything
 *  older than it: only the presence of a fragment answers, not its content. */
function syntheticLargeTranscript(): Array<{ identity: number; message: StoredMessage }> {

Pre-PR this sat above olderProbePage (fa0ff028e:1690). That function is gone and the comment re-attached itself to a helper that builds a synthetic transcript and has nothing to do with probe reads. Of everything here this is the only item that actively misinforms a reader.

2. DesktopTranscriptNavigation is a wrapper around a single number

apps/desktop/src/preload/transcript-contract.ts:27

export interface DesktopTranscriptNavigation {
  readonly navigation: number;
}

It used to carry four fields. Now every producer wraps and every consumer unwraps — preload.ts:2587 is literally navigation: navigation.navigation,. The same file contradicts it at :52 and :83, where navigation is declared bare as number. Replacing it with number in the four DesktopTranscriptHandle signatures (:105-108) drops the interface, two imports, and the wrapping at each call site.

3. transcript-parked-completion-probe.test.ts — new file, and its one assertion is already proven

Its only assertion is durableEntries() == [5, 6]: a non-contiguous durable upsert is dropped. desktop-transcript-range-store.test.ts:1098-1118 already proves that over both coversFrom shapes. The one new ingredient here is the live overlay row (21) in the loadAround snapshot — and the overlay outcome is never asserted. Either drop the file or add the overlay assertion that would justify it.

4. ChatMessageSurface re-declares and re-passes four inherited props

apps/desktop/src/renderer/chat-message-surface.tsx:92-95, destructured at :128-131, re-passed at :248-251.

ChatMessageSurfaceProps extends Omit<ComponentProps<typeof ChatView>, …> and none of hasOlderHistory / hasNewerHistory / onPrefetchHistory / onRetainWindow is in the Omit list, so all four already arrive in ...chatViewRest, which :237 spreads onto <ChatView>. Before this PR the surface did real adapter work here; what survived the refactor is plain forwarding.

One caveat: :94-95 declare the two callbacks required where ChatView has them optional. AppShell passes both, so removing them changes no behavior, but it does relax the type.

5. Two doc comments stacked on one property

packages/ui/src/use-chat-scroll.ts:59-64 — both blocks are + lines in this diff, so this looks like a merge leftover.

  /**
   * Fills the window because the reader is running out of it — never because
   * they asked to go somewhere. It must not consume an outstanding navigation
   * intent, and it must reject when the read fails so this hook can tell a
   * filled window from a failed one.
   */
  /** Fills an edge the reader is approaching. Safe to call on every frame that wants it. */
  onPrefetchHistory?(edge: 'older' | 'newer'): Promise<boolean>;

Nits

  • apps/desktop/e2e/partial-history-notice.spec.ts asserts against .maka-transcript-gap-row, .maka-transcript-history-controls and [data-transcript-gap]. No product code emits any of the three — git grep outside tests/e2e/stories finds nothing.
  • transcript-navigation-race.test.ts and transcript-pending-jump-probe.test.ts each hand-roll deferred<T>(), while @maka/core/test-only/async-primitives exports it and a dozen tests in the same directory import it. (A few older files do the same, so this is a pre-existing habit, not something this PR started.)
  • runtime-host-session-observation-registry.ts:612#bindTranscriptTarget is return this.#bindTarget(target); and #bindTarget is already generic, so the wrapper adds no typing for its two call sites.
  • transcript-scroll-cost.spec.tspeakMounted is threaded through 10 sites to feed one expect(peakMounted).toBeGreaterThan(0).
  • One fact stated twice, in prose that has to be kept in sync: "sequence numbers advance by a stride, so adjacency cannot be read off them" at transcript-contract.ts:42 and desktop-transcript-replica.ts:79. "Fills the window at an edge the reader is approaching" appears three times: chat-view.tsx:273, use-chat-scroll.ts:64, transcript-reading-position-controller.tsx:127. The contract is the one place worth stating each in.
  • Several new comments pin numbers that nothing re-verifies: use-chat-scroll.ts:181-185 restates screen * 2 / 6 / 4 from 35 lines below; :196-200 records a measurement ("an insert of 501px moves scrollTop by 501 at an offset of 1"); transcript-scroll-cost.spec.ts:44-66 duplicates the 700px viewport and an 18px row height from the test bodies, and :331-340 notes what a loaded CI runner measured.
  • Typo: packages/ui/src/__tests__/use-chat-scroll.test.tsx:440 — "and and an unmounted bookmark".

@me2seeks me2seeks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The return-to-latest refill and WorkHub pixel-band changes are sound. One failure boundary remains in the new cross-epoch bookmark recovery; attached inline.

中文摘要新增的尾部回读和 WorkHub 像素窗口修复成立;跨 epoch 书签恢复仍会在一次 landmark 查询失败后永久放弃。

if (!landmark) return;
props.sessionUi.setTranscriptReadingAnchor(sessionId, { turnId, sequence: landmark.sequence });
navigate(landmark.sequence);
}, () => undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking — keep cross-epoch re-anchoring retryable when the landmark read fails.

This rejection is reachable on the exact Host-recovery path this commit fixes, but it is silently consumed after line 192 has already advanced lastLiveGeneration to the replacement generation. Any later render for the same generation returns at line 193, so neither a subsequent message update nor the separate landmark-index refresh can retry the bookmark lookup. A single transient sessions:listTurnLandmarks failure therefore leaves a reader who was parked in history at the reset tail for the rest of that Host generation, with no error and no unavailable indication.

Keep a pending re-anchor keyed by Session/generation until the lookup succeeds or definitively reports that the Turn is absent, and retry it from the refreshed index or a later effect pass. A focused regression can reject the first landmark read, then make it succeed and rerender/update the index; the controller should eventually call loadAround for the bookmarked Turn rather than treating the epoch as handled.

中文这次查询失败发生在本提交要修复的 Host 恢复路径上,但失败被静默吞掉前,`lastLiveGeneration` 已经推进到新 generation。之后同一 generation 的任何 render 都会提前返回,独立的 landmark index 刷新也不会再触发定位。因此一次瞬时 RPC 失败会让历史区读者在整个 Host generation 内永久停在 tail,既不重试也不报错。应保留按 Session/generation 标识的 pending re-anchor,直到查询成功或明确确认 Turn 不存在。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Retracting this finding after tracing the production IPC policy. The code would indeed abandon the re-anchor if an arbitrary landmark Promise rejected, but the recovery-time transient failures I cited (connection_lost and Host draining) do not reach this branch: sessions:listTurnLandmarks is registered through handleReconnectableRead, whose router waits for the replacement handler and retries the query. I did not find a concrete, recoverable production failure left over that makes this a valuable blocker; an injected generic rejection alone is insufficient evidence. 撤回:恢复期可重试错误已在 Main 层处理,这条评论没有建立真实可达且有独立用户影响的剩余场景。

@me2seeks me2seeks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the three commits added after the prior approval. Tail refill after global cache reclaim, cross-epoch Turn re-anchoring, and WorkHub pixel-band ownership are coherent with the Renderer-owned window and have focused regressions. I retracted the landmark-retry comment after confirming recovery-time transient read failures are retried below this layer by the reconnectable IPC router. Required CI passes.

中文摘要新增三个提交复审通过:缓存回收后的尾部回读、跨 Host epoch 的 Turn 重定位、WorkHub 像素窗口接入均与单一 Renderer window owner 一致并有定向回归。landmark 重试意见经 IPC 恢复策略核查后撤回;CI 已通过。

acknowledged = through;
void (async () => {
try {
await (await current()).acknowledgeTail(through);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could tail acknowledgement be opt-in for a visible transcript reader? This generic controller is also used by readDelegatedTurnResult() in create-workhub-services.ts, which opens a transient consumer only to compute a bounded WorkHub result preview; it never renders that transcript. That consumer can reach the tail here and cause Main to advance the target Session read marker. If the delegated Turn has later durable rows, the helper can return only the target preview while acknowledging the later tail and clearing its unread state. Please disable acknowledgement for this projection, or require explicit visible-reader opt-in, with a regression that delegationFeedback does not advance the target Session read marker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed in 0a881c46c. Tail acknowledgement is no longer a property of the generic controller: createDesktopTranscriptRangeController stays silent unless the caller passes acknowledgesTail, and the only production caller that does is createRecoveringDesktopTranscriptRangeController, which every visible transcript reader (the app-shell conversation and the live WorkHub transcript) uses and no projection does — so readDelegatedTurnResult(), on the plain controller, can never move the target Session read marker. The projection never even needed to reach the tail: Main's first reset is replica.snapshot() with hasNewer: false, so it was the very first ready() that acknowledged. I enumerated every opener of a transcript consumer — four in total, two visible readers and two projections, the other projection (session-message-settlement) opening the raw handle and never having had a controller at all — so no other projection can acknowledge either. The regression is delegation feedback does not advance the target Session read marker in workhub-coordination-transcript-preload.test.ts: it drives delegationFeedback against a handle that records acknowledgeTail calls, with durable rows from a later Turn beyond the delegated one, and asserts the preview still comes back while nothing is acknowledged. Reverting the fix makes it fail with actual: [2].

@me2seeks me2seeks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MicroGery’s finding is valid and should block this head. delegationFeedback() calls readDelegatedTurnResult() as a background projection; that helper opens createDesktopTranscriptRangeController, whose subscription automatically acknowledges any tail snapshot it reaches. No transcript reader is mounted, and the returned preview can cover only the delegated Turn while later durable Turns remain unseen, yet the acknowledgement clears the target Session’s unread state through the latest visible message. Tail acknowledgement therefore needs explicit visible-reader ownership (or must be disabled for this projection helper), with the regression MicroGery requested. I am not duplicating the inline finding.

中文摘要MicroGery 的意见成立:后台委托结果预览复用了会自动 ACK tail 的可见阅读 controller,在未展示目标 Session、甚至只读取较早委托 Turn 时也可能清除更晚消息的未读状态。应让 tail ACK 由可见 reader 显式启用,或在 projection helper 中禁用,并补回归。

Three mirrors of state that already lives somewhere else, each written
and never read back.

`PendingTranscriptPage.replaces` was queued alongside every page answer.
Its strongest case was that it names the same thing as
`#admitTranscriptNavigation(request, targetId, replaces)`, so it reads
like that concept persisted; but that argument is an input consumed at
the call site, and the delivery loop only ever looks at `navigation` and
`generation`.

`DesktopTranscriptReplicaSnapshot.navigation` was a Renderer concept on
a Main type the replica never sets: the only writers were the observer's
two `{ ...snapshot, navigation }` spreads. It looked load-bearing because
`encodeDesktopTranscriptSnapshot` needs a navigation, but what the
encoder needs is `TranscriptBatchIdentity`'s field, which a second
parameter supplies. The field also reached the local transcript cache,
which stored it permanently undefined.

The controller's `lastNavigation` was identical to the store's
`#navigations` by construction — same initial value, incremented only in
`navigate()`. The extension path needs "the navigation the window sits
on" once a pending answer has landed, but that is the store's own state;
it now answers for it through a one-line `navigation()` getter.

Generated-by: Claude Code
…e's commit

WorkHub delivered window changes over a second, hand-maintained path: a
`handler(store.snapshot())` inside the batch callback, plus a second one
inside `retain` guarded by a `trimmed` flag, with a comment explaining
that a trim is the one window change nothing else delivers. That comment
was the tell — the store already has a single channel that covers both
entry points, `#commit()` behind `subscribe()`, which is how app-shell
has always been wired. Two hosts of the same store were kept consistent
by hand instead of by construction.

`store.subscribe(() => handler(store.snapshot()))` puts the callback
shape the port wants on top of that one channel, so the trim special
case and its `cancellation.aborted` re-check go with it. The
subscription is torn down both on `close()` and on the cancellation
signal, which is what made the extra guard redundant. It is also
strictly safer: the old `|| batch.ready` branch could call
`store.snapshot()` before any reset had installed a range, where
`range()` throws.

The two `if (!store.accepts(batch)) return;` guards in front of
`store.accept(batch)` went too, and `accepts` is now private. `accept()`
calls it first and returns false without decoding anything, so the guard
only ran the same test twice. It did not even cover the one case where
the two differ — a reset for another Session, which `accept()` throws on
and `accepts` admits.

Generated-by: Claude Code
`consumer.acknowledgedThrough` mirrored, per consumer, a deduplication
the Renderer already does: the window reports a watermark once, and only
while it is actually at the tail. The case for keeping it was that the
Renderer is not trusted, so a repeated acknowledgement would re-write the
read marker.

It does not hold. The write is idempotent twice over — `#markTranscriptRead`
goes to `setSessionReadMarker`, and the Host's catalog coordinator
declines a marker equal to the one it already holds. The field was
per consumer, so two windows on one Session each acknowledged and each
triggered a marker anyway; it never bought "once per Session". And a
watermark that goes backwards was never its job: `request.through <
durableThrough` rejects that, and stays.

Generated-by: Claude Code
Main threw `'Desktop transcript host epoch changed; reopen the
transcript'` and the Renderer recognised it with
`message.includes('Desktop transcript host epoch changed')` — the same
literal written twice, on either side of `ipcRenderer.invoke`, where
editing the prose on one side silently reclassifies the rejection on the
other.

The reason given for leaving it as prose is that invoke carries only a
message and no type channel. True, and already solved elsewhere in this
app: `SESSION_WORKSPACE_UNAVAILABLE_CODE` is an exported constant that
both the thrower and the matcher name, matched as a `CODE:` message
prefix. `DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE` in
`preload/transcript-contract.ts` does the same for this one; both sides
already import that module.

`TranscriptReadSupersededError` stays. Making Main silently no-op
instead is not equivalent: `restoreSessionTranscriptRange` reads
"succeeded but the Turn is not in the window" as a dead bookmark and
clears the anchor, which would break re-anchoring across an epoch
change. Superseded has to remain a distinguishable outcome.

Generated-by: Claude Code
`subscribeToReaderScroll` broadcast `(direction, phase)`. Before this
branch the direction had a real consumer: `use-chat-scroll` decided
whether to request history with `canLoad(direction) &&
nearEdge(direction)`. Moving that decision into the pixel band's own
geometry left both production subscribers ignoring the parameter — one
takes only `phase`, the other takes nothing — with the argument surviving
in the contract, in `reportReader`, and in a story.

The authority does still compute a direction, which is the case for
keeping it. But that direction serves the pin decision at scrollend; it
is not something a subscriber was using, and a subscriber that wanted
one could recompute it from geometry, as the band now does.

Generated-by: Claude Code
The transcript window work added `acknowledgeTranscriptTail` and
`loadTranscriptLatest` to the source `openTranscript` requires, so the
teardown fakes landed on main with five of the seven methods. Against this
branch `requireTranscriptSource` rejects them, `openTranscript` throws before
the fake ever runs, and the `started` deferred these tests await never
resolves — the file hangs to the 900s timeout instead of failing.

Generated-by: Claude Code
…ilable

openTranscript registers the consumer, then called requireTranscriptSource
outside the try. A source missing any transcript method therefore threw past
the cleanup every other failure takes: the registration stayed in the map and
its `ready` promise never settled, so the consumer id was permanently burned
and anything awaiting readiness hung. #restoreTranscript already made the
check inside its try; this makes the open path agree.

Regression: `releases a transcript registration whose source lacks the window
contract` attaches an observe-only source and asserts both that the open
rejects and that the registration is gone — reusing the consumer id reaches
the same failure instead of the duplicate-identity guard. Verified to fail
with the check moved back out.

Generated-by: Claude Code
…-through props

A one-field interface around a sequence number bought nothing: the preload
unwrapped it on the IPC boundary and both the wire request and the batch
payload already declared `navigation` bare, so the wrapper existed only to
be built and taken apart again. Pass the number.

ChatMessageSurface re-declared and re-passed four ChatView props that its
own props type already inherits and `...chatViewRest` already forwards.
Removing the duplication keeps them required by naming them through
`Required<Pick<…>>` rather than relying on the surface's own optionality.

Generated-by: Claude Code
The transcript range controller wired tail acknowledgement to every
consumer that reached the tail. readDelegatedTurnResult() opens one only
to compute a bounded WorkHub result preview and never renders it, so a
delegated Turn with later durable rows had its Session marked read —
returning the target preview while clearing unread state nobody saw.

Acknowledgement is now an explicit property of a reader that renders the
transcript: the plain controller stays silent unless asked, and the
recovering controller — which every visible reader uses and no projection
does — is the single place that asks. The projection cannot opt in by
accident.

Regression: `delegation feedback does not advance the target Session read
marker` drives delegationFeedback against a handle that records
acknowledgeTail, with a later Turn beyond the delegated one.

Generated-by: Claude Code
Comments that restate the code they sit on, pin a number nothing
re-verifies, or repeat a sentence the contract already carries do not
survive the next edit intact, and a stale one is worse than none. Keep
each statement where the contract lives and delete the copies.

The parked-completion probe asserted only what the range-store suite
already proves; its live overlay row now carries an assertion of its own —
verified to fail both when the coversFrom check is dropped and when the
overlay settle is.

Also: e2e assertions against selectors no product code emits, a local
`deferred` the shared test-only helper replaces, a private method that only
forwarded to a generic one, and a probe value threaded through ten sites to
feed an assertion that proved nothing.

Generated-by: Claude Code
@Astro-Han

Copy link
Copy Markdown
Contributor Author

Thanks for the two retractions — a follow-up audit of every acknowledgement path reached the same conclusions on both. The Renderer-side dedupe is still keyed by the sequence alone; the one case it can swallow is a Host replacement whose tail lands on the same number, which leaves that Session's unread state as it was until the next message, so it is recorded and left as is. The landmark read goes through handleReconnectableRead, as you found, so nothing reaches the abandoned branch. (Main's own per-consumer dedupe, acknowledgedThrough, was removed in the simplification pass — it mirrored the Renderer's and the through < durableThrough gate is what carries the guarantee.) MicroGery's finding was real and is fixed in 0a881c46c: tail acknowledgement is now opt-in, taken only by the recovering controller that every visible reader uses, with the regression delegation feedback does not advance the target Session read marker. The branch also merged origin/main now that #4562 has landed, which required giving that change's teardown fakes the transcript window contract (93e72a633) — without loadTranscriptLatest / acknowledgeTranscriptTail the source lookup threw and the suite hung. That throw exposed a real leak, fixed in 1c1c3ff1e: openTranscript rejected a source missing the window contract outside its cleanup path, so the registration and its ready promise stayed behind. HEAD is green on typecheck, architecture, lint/format, apps/desktop (2491 pass), packages/ui (418 pass) and the storybook smoke; Playwright is left to CI. A re-review would be welcome.

@Astro-Han

Copy link
Copy Markdown
Contributor Author

Thanks for the pass — all five items plus the nits are addressed.

  • Item 1 (the orphaned doc comment above syntheticLargeTranscript) — deleted in 4ee559739.
  • Item 2 (the one-field DesktopTranscriptNavigation wrapper) — removed in 4bce9ecb4; the four handle signatures take the number.
  • Item 3 (the parked-completion probe asserted nothing about the overlay) — 4ee559739; it now asserts the overlay retires and row 21 arrives once, at the end, when the reader pages forward.
  • Item 4 (props ChatMessageSurface re-declared only to forward) — dropped in 4bce9ecb4; the two callbacks stay required through Required<Pick<…>>.
  • Item 5 (two doc comments stacked on onPrefetchHistory) — both gone in 4ee559739; the type says it.
  • Nits — all taken in 4ee559739: the dead e2e selectors, the hand-rolled deferred, #bindTranscriptTarget, peakMounted, the twice-stated prose, the pinned numbers, the typo.

Two deliberate keeps, both in 4ee559739:

  • The ports.ts sentence: it is the WorkHub port's own contract, and it carries "resolves to whether a read was issued", which the signature does not say.
  • The BOUNDARY_DISPLACEMENT_MAX_PX rationale, minus the measured 18px you flagged as unverified: without the rationale the next editor raises the threshold instead of investigating.

On item 3, the new overlay assertion was ablated both ways — it fails when the coversFrom check is dropped and when the overlay settle is — so the probe now fails for the reason it exists.

@me2seeks
me2seeks dismissed their stale review September 11, 2026 12:22

Review retracted by the reviewer.

…enderer-window

apps/desktop/renderer-architecture.json: generated ledger. Took either side,
then regenerated with `check-renderer-architecture.mjs --write`; the app-shell
entry settles at the merged source's own count rather than either parent's.
Verified with `npm run check:architecture` and with `--base origin/main`, the
convention CI uses.

packages/ui/src/__tests__/prompt-anchor-rail.test.ts: main deleted the
static-markup landmark test here and reintroduced it as a mounted portal test
in the sibling file; this branch had instead rewritten it to render under the
scroll authority. Took main's deletion, since the mounted version covers the
same landmarks through the real layout. Kept this branch's `selectPromptRailTick`
import and dropped main's `selectPromptRailActiveTurn`,
`selectPromptRailTickForMountedTurn` and `PromptRailFrameScheduler` — this
branch deleted all three with the rail's own observer machinery — along with the
now-unused `TranscriptScrollAuthorityProvider` import.

packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx: took main's
`PromptAnchorRail` import and dropped `READING_BAND_TOP_PERCENT`, which no longer
exists — this branch deleted the reading-band observer it belonged to. Main's
ported portal test rendered the rail bare to assert no inline rail exists before
a host does; under this branch the rail reads its tick from the scroll authority
and throws without one, so that first render now happens inside
`TranscriptScrollAuthorityProvider`. The assertion it makes is unchanged: host
absence, not authority absence, is what leaves the rail unrendered.

Semantic conflicts, auto-merged but resolved by hand afterwards:

apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts: #5188 bounds
a sparse transcript continuation at its client range boundary, so an oversized
Turn now fills a range on its own and a reset anchored on the oldest row no
longer reaches the tail in one page. The test's invariant is reachability, not
page count, so it now asserts the reset ends at the boundary with a newer edge
and that the page behind that edge reaches the tail. Both intents kept: #5188's
bound and this branch's one-page window read.

Read against this branch's intent and left as auto-merged: app-shell.tsx (main's
WorkHub return button, shared `sessionsSelected` predicate and single-pending
interaction hydration fence sit beside this branch's transcript window props),
features/conversation/index.ts, chat-message.css (main reworked sticky activity
headers; this branch's transient-row `overflow-anchor` and gap-row removal are
disjoint), e2e-budget.json, docs/astryx-surface-file-inventory.md,
chat-surface-layout.tsx and chat-view.tsx (the rail host provider nests inside
the scroll authority, so the portaled rail still resolves both contexts).

Verified on the merge: desktop typecheck, `check:architecture` (plain and
`--base origin/main`), root `format:check` and `lint`, `apps/desktop` test:dist
(2492 pass) and `packages/ui` test:dist (419 pass), plus `build-storybook` and
`smoke:storybook` (333 stories, 360 theme renders). `@maka/mcp` test:dist fails
`user wait does not consume the network timeout` reproducibly; every input to
that package is byte-identical to origin/main here, so it is inherited rather
than introduced.

Generated-by: Claude Code
@Astro-Han
Astro-Han marked this pull request as ready for review September 11, 2026 14:11
@Astro-Han
Astro-Han merged commit 89f3839 into main Sep 11, 2026
2 checks passed
@Astro-Han
Astro-Han deleted the refactor/transcript-renderer-window branch September 11, 2026 14:17
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

6 participants