refactor(desktop): load the transcript whole and virtualize it in the renderer - #5366
Conversation
Astryx's CodeBlock splits a block over 100 lines into chunks of 20 and gives each chunk `content-visibility: auto` with a `contain-intrinsic- block-size: 20lh` placeholder. `lh` resolves against the box's own inherited line-height, and Maka's markdown contract sets 1.6 on the prose root while stripping Astryx's `:where(code, pre)` reset (scripts/build-astryx-theme.mjs), so every chunk reserved 12% more height than its lines need. Chromium then shrank the block by ~48px per chunk as it laid each one out, moving whatever the reader was on below it — the residual scroll jump that survived virtualization. Declaring the code leading on `pre` makes the placeholder and the laid out chunk the same height. Verified against Astryx 0.6.2: its CodeBlock is byte-identical here, so upgrading does not fix it. Generated-by: Claude Code
8f5961b to
fcb8a93
Compare
… renderer The Desktop transcript was a sliding window: the Main replica paged durable history in and out under the reader, and the renderer kept a range store, gap rows, prefetch bands and eviction to match. Every scroll near a boundary could change what existed, so the scrollbar resized under the thumb and both directions stuttered — the symptom apache#5315 reduced but did not remove, because the authority for what the reader can see was still moving. Move the boundary. The renderer now reads the whole durable transcript once (bounded by DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES, 64 MiB, on the first read) and never evicts, so the document's size is fixed for the session and the scrollbar means one thing. Anything past that bound is reachable through an explicit "载入更早的记录" control rather than by scrolling into it. Only the DOM is virtualized, by virtua, which keeps the mounted row count bounded without making the document elastic. That deletes the mechanisms the window needed: the server-side transcript pager, the windowed store queries and their contract, the renderer range store, gap rows, prefetch, eviction and the pin bookkeeping that existed to survive them. The wire loses the turn-range operation and its cursors, which a peer at epoch 156 still asks for, so the compatibility epoch moves to 157. Two costs are accepted rather than hidden. Offscreen Turns are no longer in the DOM, so the browser's own in-page find cannot reach them (the story asserting otherwise is deleted); transcript search already goes through the indexed path. And a load of earlier history has to hold the reader itself: virtua only shifts for a prepend while it still counts itself as scrolling, so chat-view captures the Turn under the reader on click and lands it back at the same place, including the start margin the retiring control takes with it and the size corrections the arriving rows make over the following frames. The e2e fixture grows to 500 Turns of varied prose and periodic code, because uniform rows are the one case where the virtualizer's estimate for an unmounted row is always right. Generated-by: Claude Code
fcb8a93 to
8986006
Compare
…ranscript-full-load # Conflicts: # packages/runtime-host/src/protocol/index.ts
|
整体认可这个简化方向:保留已加载历史、只虚拟化 DOM,确实能删除大量窗口预取、淘汰和导航协调逻辑;代码块行高修复也直接针对根因。 这里最主要的风险是更高的 Renderer 内存负担,需要明确: 另外,搜索或恢复到很早的 Turn 现在会反复 建议合并前补一组大历史场景的内存验证:首次打开接近/超过 64 MiB 的会话、连续加载更早记录、搜索跳到很早的 Turn,以及切换/关闭会话后的内存释放。记录 Renderer JS heap 与进程内存的峰值、稳定值和加载耗时,并说明可接受范围。无需先增加新的缓存或淘汰机制,但需要用这些数据确认这次简化的内存代价可接受,并把“64 MiB 读取预算”与“总内存上限”在描述中区分清楚。 |
virtua only corrects `scrollTop` for a row whose measurement lands while the row is entirely above the viewport. Its default measure-ahead margin is 200px and a wheel notch travels 600, so a Turn went from unmounted to straddling the viewport edge inside one notch and was always measured too late: reading upwards through the 24-Turn geometry scene moved the reader 13 times, by 5 to 194px. Mounting 2000px ahead leaves the measurement room to land before the row reaches the reader, and leaves one slip — the Turn taller than that margin, which cannot be measured in time by any margin. The geometry gate asserted `scrollHeight` never moves and `scrollTop` never runs backwards during an upward read. Both were proxies for the reader holding still, exact only while the whole transcript was in the DOM. Under DOM virtualization `scrollTop` running backwards is the correction that holds the reader still, and `scrollHeight` moves because an unmeasured row's height is an estimate. So the gate now measures the reader directly — the anchored Turn travels exactly as far as the wheel asked — and splits the two regimes: a transcript whose rows have all been measured owes an exact ride and a fixed height, while the first read over unmeasured rows is allowed the one slip above. What the estimate costs, in pixels of document resize and in how far that slip moves the reader, is recorded without a threshold. `itemSize` was ablated: opting out of virtua's estimator removed the slip but made unmeasured rows a systematic over-estimate, so the document shrank as it was read and the scroller clamped a reader near the tail back onto it. Generated-by: Claude Code
Dropping the window's eviction means nothing in the renderer trims a transcript once it is loaded, so what it costs to hold one, and whether leaving the Session gives it back, are now the open questions about this design. Neither had a number. Seeds a Session of 4,000 Turns at ~18 KiB each — ~72 MiB, past the real 64 MiB read budget, shaped as many moderate Turns rather than a few enormous ones because that is where per-message object and index overhead dominates the byte count — and weighs a real renderer across opening it, loading the rest, and leaving for another Session. The assertion is not on the height of any of those numbers, which is a product judgement recorded in the PR, but on the shape: reading the same Session twice must not cost twice. What survives a switch is a cache of the Session just left, and a cache is replaced rather than added to; a renderer that kept every transcript it had ever opened would fail here while every single-visit number stayed respectable. Generated-by: Claude Code
The first version of this measurement answered less than it looked like it did. Its fixture was 72 MiB against a 64 MiB budget, so load-earlier ran exactly once and the 18 MiB it added said nothing about whether repeated loading stays linear — which is what search back to an early Turn does. And it only ever left and returned to the same Session, so a renderer keeping one transcript per Session visited would have passed it untouched. Three Sessions now, all past the budget: one at ~216 MiB that needs three rounds of load-earlier, and two at ~72 MiB so the rotation visits distinct transcripts. Loading is linear at ~94 MiB of heap per 64 MiB read (119 -> 211 -> 302 -> 357), and the three departures leave 70.7, 70.9 and 71.2 MiB behind, against ~50 MiB apiece if they accumulated. The assertion compares each departure with the first of the same shape, so the deep Session's fully loaded remnant is not the yardstick. Seeding hundreds of MiB of durable transcript outlasts both the shared cold-start wait and the default test timeout, so this fixture raises the first and the file raises the second. Generated-by: Claude Code
It seeds ~360 MiB of durable transcript and takes minutes, which is worth paying when a number is in question and not worth paying on every push. It also earns none of what the E2E tier is for: nothing regresses when it is absent, and its assertions describe the renderer's appetite rather than a contract a change could break. So it moves to apps/desktop/perf with a config of its own, outside the E2E budget and outside every CI workflow, and is run on demand with `npm --workspace @maka/desktop run measure`. The measured numbers stay in the PR description, where a reader needs them. Generated-by: Claude Code
|
Automated review by Codex (AI), posted at the contributor's request. I would not merge revision
Testing and structure:
The architectural direction—retaining loaded transcript data and virtualizing its DOM—is reasonable. The current implementation needs focused work on actual delivered-history boundaries, Turn completeness, measurement-cache correspondence, and cancellable scroll commands before it is ready to merge. Verification: local builds succeeded; UI tests 478/478, selected Desktop tests 285/285, and selected Host tests 58/58 passed (821 total). Additional SQLite, component, and real Chromium probes reproduced the failures above. Tests ran on The visible behavior and protocol changes require independent human review under CONTRIBUTING.md. This automated review is not a human approval. |
|
Thanks — the read budget and the renderer's total footprint were indeed conflated in the description. Both are now separated, and the cost is measured rather than asserted.
So a 64 MiB read costs ~94 MiB of JS heap — about 1.5× the bytes — and each further load of the same size costs the same again. 119 → 211 → 302 MiB is linear, which is the part I could not answer before. Search back to an early Turn is that path repeated until the target is in range, so its ceiling is the whole transcript: 357 MiB of heap for a 216 MiB one. On release, the renderer holds one transcript past the switch — the Session just left — and replaces it rather than adding to it. Three different large transcripts visited in turn leave 70.7, 70.9 and 71.2 MiB behind: 0.5 MiB of growth over three visits, against ~50 MiB apiece if they accumulated. That shape is what the spec asserts, per visited Session, because it is the failure that would actually matter — a renderer keeping every transcript it had ever opened would fail it while every single-visit number above stayed respectable. The absolute numbers are recorded without a gate, since a ceiling on them would only misfire when the fixture changes. No new cache or eviction was added, per your note. The PR description now states the 64 MiB budget as a per-read bound and carries the table above. One thing the measurement is not: a CI gate. It seeds ~360 MiB of durable transcript and takes minutes, and a CI runner cannot even host it — the one round it ran there, the Runtime Host election deadline elapsed while the fixture was still being written. It also protects no contract: nothing regresses when it is absent, and its assertions describe the renderer's appetite rather than something a change could break. So it lives in 中文感谢指出——描述里确实把「单次读取预算」和「Renderer 总内存」混为一谈了,现在已经区分开,而且代价是实测出来的,不是断言出来的。
也就是说:一次 64 MiB 的读取约消耗 94 MiB JS 堆(约为字节数的 1.5 倍),而且后续每一次同样大小的载入代价相同——119 → 211 → 302 MiB 是线性的,这正是我上一版答不出来的部分。搜索跳回很早的 Turn 就是这条路径的重复,上限是整份 transcript:216 MiB 的会话对应 357 MiB 堆。 关于释放:切换之后 renderer 只留住一份 transcript,即刚离开的那个会话,而且是被替换、不是被累加。依次访问三个不同的大会话,离开后分别是 70.7、70.9、71.2 MiB——三次访问总共涨 0.5 MiB,而如果累积的话每份约 50 MiB。测试断言的就是这个形状(逐个会话比较),因为那才是真正要命的失败:一个把打开过的每份 transcript 都留着的 renderer 会在这条上挂掉,而它的单次数字可能仍然好看。绝对数值只记录、不设门禁,给它编一个上限只会在夹具变化时误报。 按你的意见,没有新增缓存或淘汰机制。PR 描述现在把 64 MiB 明确写成单次读取的界限,并附上了这张表。 有一点要说明:这个测量不是 CI 门禁。它要种约 360 MiB 的 durable transcript、耗时数分钟,而且 CI runner 根本扛不住——在 CI 上跑的那一轮,夹具还在写入时 Runtime Host 的选举截止时间就到了。它也没有保护任何契约:去掉它不会让任何东西回归,它的断言描述的是 renderer 的内存胃口,而不是某个改动会破坏的约定。所以它放在 |
Two independent defects displaced a reader who pressed "load earlier history", both introduced by the whole-transcript read. virtua indexes measured heights by position and grows that cache at the end unless `shift` says the growth is at the front, so a prepend slid every measured height one batch away from the Turn it was measured on: the document's extent went wrong and rows the reader had already been past pushed them when they came back — 200px in the new story. The answer has to be read on the render that grows the list, so it is kept in a ref keyed by projection identity; a Turn arriving at the tail must leave every measurement where it is, so only growth at the front shifts. The hold that lands the prepend installed its cancellation listeners only after the prepend landed, so input during the in-flight read — the whole IPC and storage round trip — did not abandon it: scrolling 1800px mid-flight carried the reader the full 1800px back. What the reader does during that window is newer than the hold, so the watch now starts when the hold is taken. Both are covered by browser stories over real layout, where the measurement cache and the scroll geometry actually exist. Generated-by: Claude Code
…ript The control lived inside the branch that the empty state replaces, so a transcript that renders nothing lost the only way to load the rest of it. That is exactly when it is needed: WorkHub filters the transcript to one Work, and a Work whose Turns are all still in unloaded history filters it down to nothing, leaving the reader with an empty panel and no way out. It now rides along with the empty state instead, which also keeps the list's only child a single `null` — the shape `ChatMessageList` needs to render an empty state at all. Generated-by: Claude Code
`readTurn` treated a row owned by another Turn as the end of the Turn it was reading. The Host supports nested Turns and writes their rows between the rows of the Turn around them, so the two share a stretch of the Session's ordinals — the first row of a nested Turn ended the read and the rest of the outer Turn was silently dropped. A row of another Turn is not evidence about where this one ends, and nothing short of the watermark is: the walk now filters by owner and runs to the watermark the replica holds, so a Turn is claimed whole only once the traversal that proves it has finished. The regression reads real rows back out of SQLite through the Host's own reader. It comes with the fact underneath it — a Turn's rows become durable when the Turn ends, carrying the ordinals they were written at, so a nested Turn that ends first publishes a watermark above rows the outer Turn has not published yet. Rows can arrive BELOW a watermark a reader already holds, which is why a recovering reader has to reread its range rather than catch up from the newest sequence it was given. Generated-by: Claude Code
Settlement skipped the targeted Turn read whenever the tail already held a terminal row for the required Turn, and declared the Turn complete off the merged messages. The tail is byte-bounded and can begin inside that Turn — its ending present, its earlier rows not — so a Turn with a hole in it settled as whole and the missing rows were silently dropped. A terminal row is evidence about execution, not about coverage. Only the targeted read walks the whole Turn, so completeness is now claimed only when that read comes back carrying the Turn's ending; deleting the shortcut alone would not have been enough, because the read was launched without being waited for and the tail settled the question first. A tail that moves is a reason to read again — the Turn may have been running before and have ended since — and a tail that does not is not, so a failed or incomplete read waits rather than spinning. Generated-by: Claude Code
A reset cleared the consumer's delivered boundary and rebuilt the answer from `transcriptHistoryBytes * budgets` — a count of how many times the reader had pressed "load earlier", standing in for where its history actually reached. The two diverge as soon as a budget's worth of rows is not what a Turn boundary let through: after delivering four Turns, a same-session slow_consumer recovery handed back one. The boundary itself is the fact, so a reset now reads back down to the oldest sequence this consumer was given, and the budget counter is gone. The reread stays: it is not window-era baggage. A Turn's rows become durable when the Turn ends, carrying the ordinals they were written at, so a nested Turn that ends first publishes a watermark above rows the Turn around it has not published yet — rows can arrive below a boundary the reader already holds, and only re-reading the range reconciles them. The fencing regression moves its injected failure one page deeper: a recovery now legitimately reads down to the row the consumer last had, so the page that proves failures are fenced has to sit below that. Generated-by: Claude Code
…owns it `e2e/AGENTS.md` admits an assertion to Electron only for a boundary a lower tier would miss, and says outright that real wheel input, CDP and geometry do not by themselves make one. The scroll-cost spec asserted per-frame reader displacement, thumb direction and document extent through a frame probe — all renderer-owned, and all already asserted over real layout by `UpwardTraversalHoldsTurnGeometry`, which measures drift per step rather than per frame and holds it to a pixel. What is Electron's there is the Session the Host delivered: a whole transcript arriving in one read across preload/IPC with nothing left to load earlier, walkable with real wheel input and returning to its tail. That is what the spec keeps; the probe and its tolerances are gone, and the run drops from minutes to seconds. The bound the probe also carried — mounted rows stay a fraction of the transcript — moves into the story with the rest of the traversal. The component test's prepend case gives up its scroll offset assertion for the same reason: a fake DOM with uniform 400px rows has no measurement cache to get wrong, which is exactly what the browser story was added to cover. Generated-by: Claude Code
|
Thanks. All six are reproduced and fixed, each regression failing on the commit before its fix. Five are introduced by this PR; the guard behind #2 exists identically on One of them changed what the fix was. I had planned to delete the recovery rebuild in #1 rather than repair it — with the whole transcript resident, recovery could keep what the renderer holds and append what arrived since. That is wrong, and the counterexample is now in
On the testing notes, both taken, in
On One the review did not list, which is the same contiguity assumption in the main history path: The memory-test withdrawal is noted, thanks. |
A history answer was cut into whole Turns by watching the owner of each row change, but a nested Turn's rows sit between the rows of the Turn around it, so the owner changes twice inside one Turn's extent. The answer could stop with half a Turn delivered, and the renderer had no way to tell. No local rule is authoritative here: owner change, terminal row and latest state are all proxies for an extent only the Host knows. The reader already drains mutually overlapping Turns as one cluster, so the Host now numbers those clusters and every page reports whether it stops between two of them. The Desktop reads whole pages and ends an answer only on a page the Host called whole, which lets the row-by-row `carry` buffer go away entirely. Generated-by: Claude Code
|
Follow-up to my earlier reply: the seventh finding — The Desktop cannot derive a Turn's extent locally: in nested order the owner changes twice inside one Turn, and terminal row / latest state are proxies too. The Host's transcript reader already drains mutually overlapping Turns as one cluster, so it now numbers those clusters and every transcript page carries Regression: |
…ng it A reader who scrolled while earlier history was in flight had the hold discarded outright, which left the prepend and the retiring control with nothing holding them: the reader kept their place only while the virtualizer still counted itself as scrolling and shifted its own offset, and moved 44px once it did not. The story passed on a fast machine for that reason alone and failed on CI. Scrolling is newer than the hold, but it is not a request to be moved. The hold now re-anchors on every scroll for the life of the read, so what lands is wherever the reader went last — including the height the retiring control takes with it. `PrependedHistoryKeepsMeasuredHeights` also timed out on CI at 15s. Its warm-up pass mounts every row and asserts nothing, so it takes a scrollport-sized step now; only the asserted pass is reader-sized. With a shallower fixture and a 300px step it runs in 2.7s here, and stays inside the timeout under 10x CPU throttling, where it needed 15.7s at 6x before. Generated-by: Claude Code
The page a reader is given is cut by bytes, and transcript rows are large enough that the cut nearly always falls inside a row rather than between two Turns. So `endsAtTurnBoundary` was almost never true, and a Desktop answer — which stops only on a page the Host calls whole — ran past its byte budget to the bottom of the transcript. The e2e fixture, eighteen Turns of 180 KiB against a 1 MiB budget, came back in a single answer. A page that would stop inside a group of overlapping Turns now gives that group's rows back and stops at its edge instead. Only a group that reaches the start of the page still has to be served in byte slices, which is the case the flag exists for. Generated-by: Claude Code
|
Automated follow-up review by OpenAI Codex, at The fixes address several of the original failures. In particular, retaining the recovery reread is justified by nested Turns becoming durable below an already-observed watermark. Preserving the delivered boundary is the right direction; the remaining problem is its lifetime across connection replacement. Behavior / spec findings:
The following original paths now pass the focused reproductions:
Standards review: one remaining documented-standard issue, separate from the behavioral findings. [P3] Finish removing renderer-only Electron assertions. Validation: 719 existing tests passed locally: UI 479, focused Host 55, focused Desktop 185. Dependencies and affected compiled workspaces were built; Host was rebuilt after Required conclusions:
|
The shared projection had its own copy of the page-cutting loop, because each row has to be rewritten or hidden before it is weighed. That copy never learned to stop at a group of overlapping Turns: it filled its byte allowance inside the next row and reported `endsAtTurnBoundary: false`, so a guest's history answer ran past its budget to the bottom of the transcript. Eighteen Turns of 180 KiB against a 1 MiB budget arrived as one answer where an owner got six Turns. Where a page may be cut is one question, so it gets one implementation. The reader's own page read takes the projection as an argument, and `readSharedDurablePage` — its scan loop, its hidden-byte cap and its third spelling of the boundary rule — is deleted. The Guest grant test blocked the read through `readDurableRecords`, which the shared path no longer calls; it blocks the page read itself now. Generated-by: Claude Code
Before this PR the bootstrap page carried `protectedTurnSequence`, so a cold tail was guaranteed to begin at the start of a Turn. The pager removal deleted that guarantee: a tail now begins wherever the bytes ran out, which can be in the middle of a Turn. Callers that never named a Turn — Side Chat reseeding is one — kept assuming the old guarantee, so a reconnect could leave a Turn showing its prompt and its final answer with the rows between them missing. The Host already says whether a page stops between two Turns, so the answer carries it to the Renderer and settlement reads the Turn its tail begins inside. `requiredTurnId` goes back to meaning "I need this particular Turn complete" instead of being the only thing that makes settlement check its own coverage — which the next caller would also forget to pass. Eviction clears the flag: it drops rows by their owner, and an owner change is not where a Turn ends when a nested Turn's rows sit between them. Generated-by: Claude Code
…r holds A transcript consumer belongs to the connection that created it. When the connection is replaced, `#restoreTranscript` opens a new consumer, which starts with no delivered boundary and so sends a fresh initial-budget reset — and a reset replaces everything the Renderer holds. A reader who had loaded four Turns of history got one back, with no transcript change to explain it. The earlier fix covered a reset on the same consumer, not a new one. The registration outlives the connection, so it is what carries the boundary: it records the oldest sequence handed to its target and passes it back as `resumeFrom` when it reopens. The authoritative reread stays — a nested Turn can still become durable below a watermark the reader already holds. Generated-by: Claude Code
Two Electron specs asserted per-frame geometry that the renderer owns: how far a wheel traversal travels, how many rows stay mounted, the residual distance after returning to the tail, and how far a prepend moves the Turn the reader is on. Real wheel input and CDP do not make any of that an Electron boundary, and the browser stories over real layout already assert all four — UpwardTraversalHoldsTurnGeometry, PrependedHistoryKeepsMeasuredHeights and NavigatingDuringAHistoryLoadOutranksTheHold. What is left in each spec is the boundary it actually needs: a Session under the budget crosses preload/IPC whole in one read, and one over it comes a bounded page at a time, only when the reader asks. The scroll spec is renamed for what it now proves, and its 180s timeout and wheel helpers go with the assertions they served. Generated-by: Claude Code
|
All five are fixed and pushed. Each was reproduced first, and each regression fails on the commit before its fix (verified by reverting the source and rebuilding). The four behavioral findings share a cause, and it is the mirror image of the first round's. Those fixes moved how much transcript do I have to the Host — clusters, page boundaries, the delivered watermark. Three consumers were still answering it locally, and the fourth is the same mistake one layer up, in the renderer. 1 — shared pages (P1), 2 — connection replacement (P1), 3 — Side Chat reseeding (P2), 4 — navigation vs the hold (P2), 5 — Electron geometry (P3), Ran: format, lint, typecheck, |
Rebuild the full-load transcript on main's per-event ordinals (apache#5365). Running Turns are now durable pages, so the Desktop overlay (replica overlay, overlay bootstrap page, loadTranscriptOverlay, fragment source) is removed rather than carried forward, and the protocol epoch moves to 160 after apache#5308 took 159; its compatible-change declaration is re-pinned. Review fixes re-checked under main's model: - The Turn boundary marker is computed per scan: a run starts between Turns only when every Turn the walk has entered lies behind it. Runs are single-invocation stretches, so a change of owner no longer says a page is between Turns when Turns nest. - A reset still rereads down to the oldest sequence the consumer was given, because a reset replaces what the reader holds. - The guest transcript reader now passes its projection through to readPage; before, guests saw unprojected rows. Covered by a reader test. - The test for rows published below the watermark is dropped: every committed event takes MAX+1, so that premise no longer holds. Generated-by: Claude Code
A Turn is not an invocation: nested Turns interleave, and a handoff or continuation carries one turnId across invocations. Readers inferred Turn boundaries from adjacency and each got a different case wrong. runtime_session_turn_extents records, per visible Turn, the first and last Session ordinal any of its invocations owns. It is widened in the same transaction that assigns an ordinal, rebuilt when ordinals are resequenced, backfilled by migration 19, and deleted with the Session. Two queries sit on it: readTranscriptTurns (lookup by turnId, or an evenly spaced sample for the rail, labelled by each Turn's first user text) and readTranscriptTurnCrossing (whether a page may end at an ordinal). Only the prompt event is loaded for a label, so listing Turns does not project whole Turns. Generated-by: Claude Code
The reader decided whether a page may end before a run by tracking how far the Turns it had entered reached. That only sees Turns inside the walk: a handoff or continuation resumes one turnId in a new invocation, so a page could end between the pause and the resume and split the Turn. A page may now end before a run only when the Turn extent index says no Turn has events on both sides of the run's edge, which covers nesting, handoff, and continuation with one query per run. Generated-by: Claude Code
When the Host drops while a transcript is cached, the reopen fails but the error was swallowed because the cached transcript was still showing, so reconnect recovery never learned the transcript had failed and never reloaded it once the Host returned. Reloads now report their failure to recovery before rethrowing; only the user-facing error stays suppressed while the cache is shown. Generated-by: Claude Code
Restores session.turn_landmarks.query, backed by the Turn extent index instead of scanning invocations. A client can sample the Session's Turns for the prompt rail, or pass a turnId to learn where one Turn starts without reading the transcript up to it. A landmark's sequence is the Turn's first ordinal, so reading older history through it always includes the whole Turn. A looked-up Turn is returned even without a user prompt; only sampled ticks need a label. The grant migration that adds the landmark query beside session.turns.query is restored. main took epoch 160 for another change, so this branch's transcript contract moves to 161. Generated-by: Claude Code
…ull-load # Conflicts: # packages/runtime-host/src/protocol/index.ts
A landmark now carries the last sequence any row of the Turn can have, so a client reading one Turn can stop there instead of walking to the watermark, which it had to because a nested Turn's rows sit between the rows of the Turn around it. Committed without the staged epoch hook: it compares against HEAD, where this branch already moved the epoch to 161; the merge-result check against main (160 -> 161) is the authority. Generated-by: Claude Code
Main exposes the restored landmark query to the Renderer, sampled for the prompt rail or looked up for one Turn. Two reads that had to walk the Session now use it: - readTranscriptTurn found a Turn's start by paging every Turn contribution, then read forward to the watermark because nothing said where the Turn ends. It now takes both ends from the landmark. - loadEarlierTranscript takes an optional throughSequence and keeps reading in the same answer until the history reaches it, so jumping to a far Turn is one answer rather than one Renderer round trip per budget. This reuses the floor a reset already reads down to. Opening a transcript also accepts resumeFrom, so a Renderer that reopens its own consumer can keep the history it holds; the registry already did this when it replaced a consumer after a reconnect. Generated-by: Claude Code
…nderer The prompt rail now lists the whole Session from `session.turn_landmarks.query` instead of only the loaded range, and a bookmark or search target outside the loaded range is located through the same index and read down to with one `loadEarlier(throughSequence)` request, replacing the page-by-page walk. - Range controller: `loadEarlier(throughSequence?)` chains after an in-flight read and skips a target already held; a reopen passes the oldest held sequence as `resumeFrom` so Main restores the range the reader had. - Restore: resident target completes; otherwise one index lookup and one targeted read, then land or report unavailable. A Turn the index does not know, or a viewer without index access (Guest), is unavailable instead of walking history. - Rail: indexed Turns outside the loaded range are listed before it; a tick for one holds a landing for that Turn and requests history down to it. The prepend hold lands it at the top, because virtua's prepend shift overrides a direct scrollToIndex issued in the same commit. - The index is read once per Session when history lies outside the loaded range; new Turns always land in the resident tail. Generated-by: Claude Code
…tion case The fixture seeds PROMPT_RAIL_PROMPT_COUNT (500) Turns; the case still waited for turn-prompt-rail-120 as the tail, so every session switch timed out. Generated-by: Claude Code
…list changed Virtualization turns the DOM into a cache, but several writers still treated it as the truth: the search land kept scrolling after a pin, the reader hold ignored the pin, the prepend shift guessed from the first Turn id and so mis-shifted interleaved or filtered lists, and a selection end outside every Turn was dropped, unmounting part of the selection. The scroll authority now owns all three states: pinned, positioning one Turn (a navigation, or keeping the reader's Turn while the list changes under them), or writing nothing. Every command and every reader input ends a positioning, so the parallel mechanisms go: subscribeCommands, holdReader, pendingReveal, the reader-input hold and the chat-view measurement shift. useChatScroll classifies each list change as append, prepend or reset. Prepend shifts virtua's position-indexed height cache; reset starts a new cache, because no cached position means what it did. The authority keeps a released reader on their Turn at the same gap for either. A selection end outside every Turn now counts as before or after all Turns by document order, so every Turn it spans stays mounted. Accepted cost: disclosure state the reader expanded inside a row resets when that row unmounts; lifting it would mean owning those components. The LinkeDOM test DOM gains scroll-consistent rects and the Node.DOCUMENT_POSITION_* constants production code reads. Generated-by: Claude Code
…ixture The prompt-rail fixture grew to 500 Turns, but the geometry and scroll-input specs still waited for Turn 120. Scroll input also jumped to the top of the loaded page before measuring, which with the whole history loaded is Turn 1 and left nothing to travel; it now travels 60 Turns up from the tail, and bounds mounted rows by that travel instead of by placeholder rows that no longer exist. Generated-by: Claude Code
The case added with the Renderer's Turn index lookup needs the Host's Turn extent index and a read down through preload and Main, so it is an Electron round trip; the budget still recorded one test for the spec. Generated-by: Claude Code
jackwener
left a comment
There was a problem hiding this comment.
Independent agent review. Reviewed at 30dbb56edf66bdf95ab316cfdfce26fb558d6b99. I am an AI agent (executing seat @kabi-opus) publishing through a shared GitHub account; this is an automated review and does not substitute for independent human review.
No P0–P2 findings. One precision note on the byte bound, and a clear statement of what no reviewer verified.
129 files and a net −2,536 lines, so I went at the deletions, the new bound, the new dependency, and the gate — the four places where a refactor this size can quietly lose something.
The gate change is a correction, not a relaxation
Replacing a gate's assertions is where a weakening hides, so this was my first stop. The old gate asserted that scrollHeight never moves and scrollTop never runs backwards. Under virtualization those are not weaker proxies — they are wrong: a backwards scrollTop is precisely the correction that holds the reader still while a row above is measured. A gate that fails on correct behaviour is worse than no gate.
What replaces it measures the reader directly and hard-asserts the strict case:
expect(row.warmReaderSlips, `${scene}: measured transcript moved the reader`).toBe(0);
expect(row.warmHeightDrift, `${scene}: measured height drift`).toBeLessThanOrEqual(1);
expect(row.coldReaderSlips, …).toBeLessThanOrEqual(1);toBe(0) on the measured case is stricter than anything the old gate expressed. The single allowance is confined to reading over unmeasured rows, with the mechanism stated in the comment rather than asserted as a magic number.
The 64 MiB bound is a floor plus a Turn, not a ceiling
The description says the first read is "bounded ... by DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES (64 MiB)". The enforcement is:
if (bytes >= budget && reachedFloor && page.endsAtTurnBoundary) break;All three conditions must hold, and the comment above it is explicit that "a page that leaves a Turn half-read is read past, however much of the budget has already been spent." So the real contract is at least 64 MiB, then rounded up to the next Turn boundary — the overshoot is one Turn, unbounded in principle by this check alone. A separate hard guard does exist (#adjustTranscriptDeliveryBytes throwing "Desktop transcript delivery capacity was reached"), so this is not unbounded memory; it is a description that reads like a ceiling for something that is a floor.
Not a defect — refusing to show half a Turn is the right call, and it is documented in the code. But "bounded by 64 MiB" is the sentence a reader will quote when reasoning about worst-case memory, and one large Turn past the floor is the part that sentence hides.
The new dependency is handled correctly
virtua is pinned exactly ("virtua": "0.48.8", no range) in packages/ui/package.json — the right choice for something this load-bearing. It is MIT, and it already appears in apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt at the same version with declared and selected license both MIT, so the notice generator was actually run rather than the entry being assumed. Installed size is 1.6 MB.
Evidence
Built core, storage, runtime, runtime-host and ui from a cleaned dist: zero TypeScript errors. UI 489/489. Desktop main 2665/2665 after build:test (main, preload and overlay — build:main alone leaves dist/overlay missing and produces six unrelated ENOENT failures).
A note for anyone re-verifying: this PR adds a dependency, so an existing checkout needs npm install before the build will typecheck. Without it you get Cannot find module 'virtua' in chat-view.tsx and use-chat-scroll.ts plus cascading implicit-any errors, which look like defects in the diff and are not.
What no one verified
Every headline number in this PR rests on real Electron, and no reviewer ran it. The geometry gate itself imports electron and Playwright's _electron, so --assert-stable cannot run in my environment at all — meaning the gate that backs "ticks that moved the reader go 13 → 1", "≤0.2px displacement against main on the 500-Turn fixture", and the itemSize ablation is unexecuted here rather than passing. Those figures are the author's measurements.
I invited the one seat with working Electron on this repository twice; they declined both times, on the explicit grounds that taking a 129-file change needing real browser pressure evidence while already holding another review would compress evidence quality on both, and asked to be recorded as not counted. That is the right call on their side and I am recording it as they asked: this is a single-seat review, and the seat that could close the gap is not in it.
Relationship to #5365
This branch already contains #5365's model — transcript-overlay-settlement.test.ts is deleted, the overlay.release grant migration is present, and pendingRunningStartedAt has zero occurrences, matching that branch exactly. The description says so directly ("the merge rebuilt this branch on that model"). The 34 overlapping files are therefore an ordering question, not a conflict: #5365 first, then this. Its epoch (161, after #5308's 159 and main's 160) is consistent with that order.
Code review, CI status and merge readiness are separate. At this SHA mergeable=MERGEABLE, mergeStateStatus=BLOCKED. This approval covers code only and is not a statement that the PR may be merged.
…y again The whole-transcript refactor deleted the shell's Turn index state and lowered the inventory to 10; the Renderer's Host Turn index lookup brought the same state back, so the count returns to main's 11. Generated-by: Claude Code
…apter The shell and WorkHub each wired the bridge's transcript open by hand, and the resume-from-held-range argument grew both copies, pushing the shell's effects file over its renderer-architecture debt ratchet. One adapter next to the range controller now owns the cancellation wiring, the history mode and the resume sequence; the shell's file ends smaller than on main. A batch for an aborted open is dropped on both paths, as WorkHub already did. The ledger also records the Turn index state and listTurnLandmarks the Renderer lookup added to app-shell.tsx, both of which main already carried. Generated-by: Claude Code
…lity The Turn extent index decided visibility from the opening in two places with different assumptions. The migration backfill ran `json_extract` over every opening, so one undecodable payload failed migration 19, rolled its version back and left the next open to fail the same way. The per-event write assumed the opening was already committed; an event committed before it never entered the extent, with nothing to reconcile it. The visibility predicate now checks `json_valid` before extracting, for ledger and legacy openings alike, and every reader and writer shares it. An opening covers every event its invocation already committed, so the extent no longer depends on commit order. Generated-by: Claude Code
`readTurn` trusted every page the Host returned: an empty page with a continuation looped forever, and a page that went back over rows already read passed unnoticed. It now applies the same checks as catch-up — no empty continuation, rows in order within a page, and each page starting where the previous one ended. Generated-by: Claude Code
Reaching a Turn outside the loaded history resolved a promise and took that as the outcome. A range that reopened during the read — a reconnect, a gap reload — drops the earlier answer while `loadEarlier` still resolves, so the restore latched `loaded`, found no Turn, cleared the bookmark and reported it unavailable; a search target just stopped. The restore now counts a read only when the range generation it started in is still current, and otherwise looks the Turn up again. The Turn index read follows the same fact: it is read again when the range reopens, so a lookup that failed during a reconnect does not leave the rail showing only loaded Turns for the rest of the Session. The previous index stays on screen until the reread answers. Generated-by: Claude Code
A rail tick outside the loaded history navigates to its Turn and waits for it. When the read failed or did not bring the Turn, the navigation stayed pending: reading-position reports stayed suppressed, and an unrelated earlier-history load later pulled the reader to that Turn. A navigation now carries what brings its Turn. Once that settles and the list has rendered, the navigation lands if the Turn is there and ends if it is not. Generated-by: Claude Code
transcript-full-delivery timed out on CI three times waiting for the
500-Turn fixture window's first rendered Turn, never reaching its own
assertions. The contract it guarded (a Session under the history budget
arrives whole, nothing left to load earlier) is already asserted in
runtime-host-session-observer.test.ts ("delivers history in whole Turns
within the budget and continues exactly on load earlier"). Its
promptRailWindow fixture had no other user; the chat-prompt-rail scenario
stays for the perf specs.
Generated-by: Claude Code
…Storybook The rail-tick read-down e2e case duplicated unit coverage at every step: the tick asks for history down to its Turn (prompt-rail-reading-position), the Host answers in one read (runtime-host-session-observer), and the navigation waits for its Turn to arrive (transcript-scroll-authority). It is removed and the spec's budget returns to one test. The remaining paging case no longer waits 500ms to show that scrolling loads nothing, which the deleted prefetch path was the only way to do, and waits on the control leaving its pending state instead of wheeling to the top on every poll. NavigatingDuringAHistoryLoadOutranksTheHold guarded a hold taken when load earlier is pressed; that hold no longer exists, and where a prepend leaves the reader is decided by the scroll authority from the reading Turn at the time rows arrive. It becomes a use-chat-scroll unit test. PrependedHistoryKeepsMeasuredHeights stays in Storybook: it needs virtua's real measurements. Generated-by: Claude Code
… rail
Clearing the Work filter is a list change other than growth at an edge, so
the transcript virtualizer now starts over with a new key and remounts every
row. FilterWorkConversations focused the answer rail straight after the
click; on a slow CI runner that element was the one about to be replaced,
so Enter reached a detached button and the filter never applied ("expected
length 2 but got 4" at the answer-rail step, in both viewport variants).
The story now waits for the four rows and asserts the rail holds focus.
Generated-by: Claude Code
Summary
The Desktop transcript was a sliding window: the Main replica paged durable history in and out under the reader, and the renderer carried a range store, gap rows, prefetch bands and eviction to match. Scrolling near a boundary changed what existed, so the scrollbar resized under the thumb and both directions stuttered. #5315 reduced that; it could not remove it, because the authority for what the reader can see was still moving while they read.
This moves the boundary. The renderer reads the whole durable transcript once — bounded on the first read by
DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES(64 MiB) — and never evicts, so the set of Turns is fixed for the life of the session. Anything past that bound is reached through an explicit 载入更早的记录 control rather than by scrolling into it. Only the DOM is virtualized, byvirtua, which bounds the mounted row count without making the set of Turns elastic.That deletes the server-side transcript pager, the windowed store queries and their contract, the renderer range store, gap rows, prefetch, eviction, and the pin bookkeeping that existed to survive them. The deletion and the whole-transcript read change the same reader contract in the same files, so they land in one commit; the code leading, the measure-ahead margin, the memory measurement, each review fix and the test-tier move stand alone and revert alone.
Since #5365 every committed event takes the next ordinal and a running Turn's rows are durable pages, so the Desktop overlay that carried the running Turn beside durable history is gone too — replica overlay, overlay bootstrap page,
loadTranscriptOverlayand the fragmentsourcefield. The merge rebuilt this branch on that model rather than carrying the overlay forward.The wire loses the turn-range operation and its cursors, a review fix adds one field to the transcript page, and
session.turn_landmarks.queryreturns backed by a Turn extent index with alastSequenceper landmark, soRUNTIME_HOST_COMPATIBILITY_EPOCHlands at 161 (#5308 took 159,mainhas since taken 160) — mismatched peers refuse each other at the handshake rather than at a read.Two separate commits fix jumps that survive the refactor or arrive with it:
CodeBlockgives each 20-line chunkcontain-intrinsic-block-size: 20lh, andlhresolves against what the box inherits. Maka's markdown contract setsline-height: 1.6on the prose root whilescripts/build-astryx-theme.mjsstrips Astryx's own:where(code, pre)reset, so every chunk reserved 12% more height than its lines need and Chromium shrank the block by ~48px per chunk as it laid each one out. Declaring the code leading onpremakes placeholder and chunk agree. Astryx 0.6.2 ships the sameCodeBlockbyte-for-byte, so upgrading does not fix it.virtuacorrectsscrollTopfor a row measured while it is entirely above the viewport and declines to correct one still straddling the top edge. Its measure-ahead margin defaults to 200px; a wheel notch travels 600, so a Turn went from unmounted to straddling within one notch. Mounting 2000px ahead gives the measurement room to land first: on the 24-Turn mixed scene, ticks that moved the reader go 13 → 1, and 0 once every row has been measured. Againstmainon the 500-Turn e2e fixture, reader displacement is ≤0.2px in both directions where the pager moved it 48–239px at code blocks.itemSize— a constant in place of virtua's estimator — was tried and ablated away: it is a systematic over-estimate, so the document shrank as it was read and the scroller clamped a reader near the tail back onto it.The geometry gate (
scripts/perf/geometry-ablation.mjs --assert-stable) asserted thatscrollHeightnever moves andscrollTopnever runs backwards. Both were proxies for the reader holding still, exact only while the whole transcript was in the DOM — under virtualization,scrollToprunning backwards is the correction that holds the reader still. The gate now measures the reader directly (the anchored Turn must travel exactly as far as the wheel asked), hard-asserts it once every row has been measured, allows one slip on the first read over unmeasured rows, and records the cost of estimating without a threshold. Its membership assertion accumulates Turn ids across the sweep and requires the union to be exactly the fixture, instead of requiring all 24 rows at every instant.Refs #5315
Review fixes
@liuxiaocs7's review found seven defects; CI found an eighth in the fix for the fourth; a follow-up review found five more; an internal review traced the rest to one missing authority, fixed under Turn extent index. All were reproduced first — most against the real Host, real SQLite or real layout — and each regression fails on the commit before its fix. Each was re-checked after #5365; the table says where the fix changed shape.
Two share a cause: a resident whole transcript removes eviction, not the need to know where a Turn begins and ends. Turns nest: a Turn started inside another takes ordinals between the outer Turn's rows, so the owner changes twice inside one Turn's extent, and a change of owner says nothing about whether a Turn is complete. (This description earlier also claimed rows could arrive below a watermark the reader already holds, because a Turn's rows became durable only when it ended. #5365 removed that — every event is durable at MAX+1 — and the test built on it is deleted.)
runtime-host-session-observer.tstranscriptHistoryBytes × budgets— how many times the reader had pressed the control, standing in for where its history reached. After four Turns, a same-sessionslow_consumerrecovery handed back one. The boundary is the fact, so a reset reads back down to it; the counter is deleted. The reread stays because a reset replaces everything the reader holds: stopping at one budget would take back history the reader had loaded.session-message-settlement.tsdesktop-transcript-replica.tsreadTurntreated a row owned by another Turn as the end of its target. Ownership filtering now runs to the watermark: a row of another Turn is not evidence about where this one ends.runtime-host-session-observer.ts,session-transcript-reader.tscarrybuffer outright.session-transcript-reader.tschat-view.tsxvirtuaindexes measured heights by position and grows that cache at the end unlessshiftsays otherwise, so earlier history arriving detached every height from the Turn it was measured on — 200px of displacement on revisiting rows the reader had been past.shiftis computed per projection identity, and only when the list grew at the front.use-chat-scroll.tschat-view.tsxThe follow-up review's five share a cause too, and it is the first round's mirror image: the fixes above moved how much transcript do I have to the Host, and three consumers were still answering it locally — a second pager, a per-connection consumer, and a tail assumed to begin where a Turn begins. The fourth is the same mistake in the renderer, where reader input stood in for an authority that already exists.
session-transcript-pager.ts,session-transcript-reader.tsreadSharedDurablePageis deleted. Re-checking this after #5365 found the reader's lazily prepared wrapper dropping the projection argument, so a guest page was read unprojected; it now passes through, and the test reads with a projection that hides every row.runtime-host-session-observation-registry.tsdesktop-transcript-replica.ts,session-message-settlement.tsbeginsAtTurnBoundary, and a caller that names no Turn still gets a whole one. Eviction clears the flag: it groups rows by owner, which is not where a Turn ends.use-chat-scroll.ts,transcript-scroll-authority.tsxtranscript-full-delivery.spec.ts,partial-history-notice.spec.tspartial-history-notice.spec.tskeeps bounded pages on request.transcript-full-delivery.spec.tswas later removed too: on CI it timed out cold-starting its 500-Turn window before any assertion, and the whole-read contract it guarded is asserted inruntime-host-session-observer.test.ts. A secondpartial-history-noticecase (a far tick reads down once and lands) was dropped for the same reason: each step already has a unit test.NavigatingDuringAHistoryLoadOutranksTheHoldbecame ause-chat-scrollunit test; onlyPrependedHistoryKeepsMeasuredHeightsneeds real layout and stays a story.Turn extent index
A third, internal review found the remaining defects share one cause: where a Turn is and where a Turn ends were still answered by local rules in three places. The page reader inferred boundaries from the rows it walked, so a Turn enclosing a page without rows on it went unseen. The renderer found a bookmark or search target by loading page after page until it appeared, which was unbounded and stalled on a page that did not advance. And the rail could only tick Turns already read. Retiring
session.turn_landmarks.querywith the window had removed the one authority for both questions, so it comes back on a table that cannot drift from the transcript.runtime_session_turn_extents(session_id, turn_id, first_ordinal, last_ordinal)is written in the same transaction as each visible ordinal insert. It is rebuilt when ordinals are resequenced, backfilled by schema migration 19, and deleted with the Session. A handoff or continuation that reuses aturnIdwidens the same extent.session-transcript-reader.ts,runtime-transcript-query.tsreadTranscriptTurnCrossingfinds no extent spanning its edge. The walk's reach tracking and handoff-neighbour special case are deleted, and so is the accepted cost that a Turn the page never visited could be missed.protocol/session-turns.ts,session-catalog-coordinator.tssession.turn_landmarks.queryanswers from the index: up to 64 sampled, labelled landmarks for the rail, or one Turn'ssequenceandlastSequencebyturnId. Owner credentials holdingsession.turns.queryare migrated to the new grant as well.runtime-host-session-observer.ts,desktop-transcript-replica.tsreadTranscriptTurnreads through the Turn'slastSequenceinstead of scanning to the watermark.runtime-host-session-observer.ts, IPC, preloadloadEarlier(throughSequence)asks Main for everything down to a sequence in one answer, still ending only between Turns. A reopen passes the oldest held sequence asresumeFrom, so a reconnect restores the range the reader had rather than a budget.transcript-reading-position.tsprompt-anchor-rail.tsx,chat-view.tsx,use-chat-scroll.tsdesktop-transcript-range-store.tsscripts/perf/*.spec.tsOne owner for scroll writes
The same review's last cause: virtualization makes the DOM a cache, but several writers still treated it as the truth, and each took turns at
scrollTopwith its own idea of the list.use-chat-scroll.tspinToTail.use-chat-scroll.tsholdReaderrestored the reader's Turn even when the transcript was following its tail.chat-view.tsxshiftwas set whenever the first Turn id changed and the list grew, so a cleared WorkHub filter — Turns returning between the ones held — shifted virtua's height cache by the wrong amount and detached every height from its Turn.chat-view.tsxThe fix is not four patches.
transcript-scroll-authority.tsxnow owns every intentional write in exactly one of three states — pinned, positioning one Turn at one place, or writing nothing — and every command and every reader input ends a positioning. A positioning is either a navigation (bookmark, search, rail; it can wait for a Turn not loaded yet) or keeping a released reader's Turn at the same gap while the list changes under them.useChatScrollclassifies each list change as append, prepend or reset: a prepend shifts virtua's cache, a reset starts a new one through the Virtualizerkey, because no cached position means what it did. That deletessubscribeCommands,holdReader,pendingReveal, the reader-input hold and the chat-view shift heuristic; the command-seam fix in the follow-up table above is superseded by it.Second internal review
Its findings reduced to two causes.
Reaching a Turn took a settled request for an outcome. A far jump is a lookup, a read down to the Turn and a navigation, and each step treated its promise resolving as having got there.
transcript-reading-position.tsloadEarlierstill resolves, so the restore latchedloaded, found no Turn and cleared the bookmark; a search target just stopped. A read now counts only if the range generation it started in is still current; otherwise the Turn is looked up again.transcript-reading-position-controller.tsxtranscript-scroll-authority.tsx,chat-view.tsxdesktop-transcript-replica.tsThe Turn extent index decided visibility twice, under different assumptions.
runtime-transcript-query.tsjson_extractover every opening, so a malformed payload failed it, rolled the version back and failed the next open the same way. The shared visibility predicate checksjson_validfirst.runtime-transcript-query.tsChecked and not changed:
turnAtdoes not need the start margin subtracted (virtua'sfindItemIndexalready does), and the landmark result is decoded and identity-checked at the Host protocol boundary.The staged protocol-epoch hook compares against
HEAD, so it rejects a merge commit and any later protocol commit on a branch that has already bumped. The merge andfeat(runtime-host): say where a Turn ends in its landmarkwere checked withnode scripts/protocol-epoch-check.mjs --base origin/main --head <rev>and committed with--no-verify.Not changed: WorkHub's
resultPreview(create-workhub-services.ts) computes and caches a 600-char preview that nothing renders, onmaineither. #5150 said it would be displayed and that never landed; deleting a shipped intent inside a refactor is the wrong place for that decision, so it is raised separately. It is a cross-SessionreadTurnconsumer, so it does get the nested-Turn fix.Accepted costs
Reviewing this PR means agreeing to these.
OffscreenActiveTurnsStayFindable) is deleted; transcript search already goes through the indexed path.readTranscriptTurnkeeps waiting for the Host and holds the Session's observation until the Host answers. It releases once the Host recovers.virtuashifts its offset for a prepend only while it still counts itself as scrolling (it stops 150 ms after the last scroll), and a reset discards its measurements. The scroll authority records the reader's Turn and gap and places it back for 30 frames while the rows around it measure, including the start margin the retiring control takes with it.DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES. That constant bounds a single history read; loads of earlier history and new messages accumulate on top of it, and DOM virtualization bounds mounting, not residency.apps/desktop/perf/transcript-memory.spec.tsweighs that in a real renderer and asserts the shape that matters — a switch away replaces the retained transcript rather than adding to it. Measured numbers: a 64 MiB read costs ~94 MiB of heap, each further load the same again, and three large transcripts visited in turn leave 70.7 / 70.9 / 71.2 MiB behind. It seeds ~360 MiB and takes minutes, so it is not a CI gate; run it withnpm --workspace @maka/desktop run measure.New dependency
virtua@0.48.8, MIT (Category A), no runtime dependencies; only its React adapter reaches the renderer bundle, andTHIRD_PARTY_NOTICES.txtcarries its licence. It is the one thing this PR adds rather than deletes.Reading the whole transcript and mounting the whole transcript are separate decisions: the first fixes the set of Turns, the second is what a virtualizer prevents — asserted over real layout in
UpwardTraversalHoldsTurnGeometry. What is hard is not rendering a slice; it is measuring rows of unknown height, compensatingscrollTopwhen a measurement above the reader changes one, and keeping the reader still across a prepend. LobeHub virtualizes its chat transcript withvirtua ^0.48.8— same library and version — in a 418-line component; OpenCode uses@tanstack/solid-virtual(605 lines plus 75 for a scroll-offset observer) and carries a patch against@tanstack/virtual-corefor exactly the prepend case handled here. Neither wrote their own.Verification
After the second internal review's commits:
npm run format,npm run lint,@maka/desktoptypecheck,check-renderer-architecture --base origin/main --strict-base@maka/storage(1397 pass, 8 skipped; one staledistfile with no source),@maka/desktopmain (2668/2668),@maka/ui(490/490)partial-history-notice,transcript-full-delivery,workhub-return-rail,session-local-recovery(8/8)The storage regressions were not replayed against the previous commit; the malformed-JSON failure was confirmed directly against SQLite.
After the scroll-owner commits:
npm run format,npm run lint,@maka/desktoptypecheck (preload, main, renderer, storybook)@maka/ui(489/489),@maka/desktopmain (2665/2665)partial-history-notice,transcript-full-delivery,workhub-return-rail,workhub-layout,streaming-remount,quote-window-boundary,session-local-recovery(16/16)frontend.spec.ts,geometry-navigation.spec.ts,scroll-input.spec.ts— dense upward input travels 60 Turns in 240 wheel ticks with at most 12 rows mounted and no long taskThe new authority tests drive frames directly, so a list change, a superseded navigation and reader input ending a positioning are asserted in the unit tier. They were written against the new interface and were not replayed against the previous commit.
After the Turn extent index commits, including the merge of
mainthat took epoch 160:npm run format,npm run lint,@maka/desktoptypecheck (preload, main, renderer, storybook)@maka/storage(1389 pass, 0 fail),@maka/desktopmain (2687/2687),@maka/ui(484/484)@maka/runtime-hosttranscript reader, session-turns and authenticated-websocket grant testspartial-history-notice(with a new case: a far tick reads down once and lands),transcript-full-delivery,workhub-return-railscripts/perf/frontend.spec.tsA regression test for each fix fails on the commit before it. The landing position after a rail jump is measured only in the e2e, because the unit DOM runs no frames.
After the earlier merge with
main(#5365, #5308, #5368):npm run format,npm run lint,@maka/desktoptypecheck (preload, main, renderer, storybook),@maka/desktopmain (2658/2658),@maka/ui(482/482),@maka/storage(1378 pass, 8 skipped),@maka/runtime-hosttranscript and session suites (378/378),check:e2e-budget,check:renderer-architecture,check:asf-headers, and e2epartial-history-notice,transcript-full-delivery.Before the merge, and not rerun since: the rest of
@maka/runtime-host,check:app-shell-hooks,check:locale-hygiene, the full Storybook render smoke (367 stories, 396 theme renders), the geometry gate on all three scenes, and e2equote-window-boundary,session-local-recovery,streaming-remount. CI covers these.Not run: the whole-repo suite.
One caveat on the gate: its cold-sweep bound is one slip, and on a busy machine the 24-Turn mixed scene occasionally costs two — three of five local runs gave one, two gave two, with the worst displacement unchanged at 365px and the warm sweep exact every time. It has been green on CI on every revision so far. The count depends on whether an oversized Turn is measured before the reader reaches it, which is a scheduling question, so the bound may need to be stated per oversized Turn rather than per sweep.
Same story (
NestedScrollerNearHistoryBoundaryAsksForNothing), same viewport, BEFORE frommain's storybook-static:The code-leading commit has no steady-state pixels to show — it removes a transient shrink during layout.
AI use
Select exactly one:
Tool(s) and scope: Claude Code — implementation, the
virtuaand Astryx source reading behind the root causes, the measurement harnesses, the merge withmain, and this description.Checklist
Does this PR entail a change in behavior?