Skip to content

feat(desktop): preserve transcript continuity across bounded ranges #4123

Description

@Astro-Han

Problem

#4206 replaced Renderer turn virtualization with a bounded Runtime Host range. That change substantially improved long-transcript performance and removed a second scroll and layout authority.

One problem remains. When the resident range does not reach the transcript's real edges, the reader is given no marker where the unloaded history is. TranscriptHistoryNotice (packages/ui/src/chat-view.tsx) is a banner at the top of the section, not a row at the position of the break — so a live overlay Turn appended after an older durable window reads as continuous with it, and there is nothing at the break to navigate from.

Correction (2026-09-04)

Two earlier statements in this issue were wrong, and one of them shaped an implementation that is larger than the problem. Both are now removed from the text above and below; recording them here so the change is auditable.

"This can make separated ranges appear adjacent." The resident durable set is always a single contiguous window. #evictToBudget (apps/desktop/src/main/desktop-transcript-replica.ts) only advances the oldest index and retreats the newest index — it never removes from the middle — and #acceptRange throws on an internal sequence gap while durable coverage is complete. Two non-adjacent durable ranges cannot be resident at once.

"Desktop no longer has enough position information to represent them." Everything needed to place and label a break already crosses the preload boundary: each durable fragment carries its identity (durable sequence), and batches carry durableThrough, hasOlder and hasNewer; loadBefore(anchorSequence) and loadAround(sequence) are the existing entry points. From these the renderer can already derive how many messages are missing at each edge, where the break is, and which sequence to request. The one thing it cannot derive is how many Turns a break contains, because message count is not Turn count.

The former Direction section then prescribed an implementation — maintain Turn positions incrementally, page them with a keyset cursor and a revision or watermark — and the third acceptance criterion presupposed a position snapshot exists. That is a solution written into an issue, and it is the reason #4560 spans Storage schema, a new pager, a signed cursor and a protocol epoch bump. The direction below states the outcome instead.

Current boundary

flowchart LR
    S[Storage<br/>complete transcript] --> P[Runtime Host pager<br/>bounded bodies]
    P --> D[Desktop<br/>bounded projection]
    D --> R[Renderer<br/>single scroll authority]
Loading

Direction

Represent a break in the transcript where it actually is, using what already crosses the boundary:

  • Project the transcript as rows, where a row is either a Turn or a gap, and place the gap at the break rather than above the transcript.
  • Let a gap be actionable: loading from it goes through the existing loadBefore / loadAround / latest-range entry points.
  • Continue loading message bodies through the existing session.transcript.page path.
  • Keep the transcript scroll authority the only writer of scroll position; a gap row must not introduce a second one.
  • Deliver the first change as an end-to-end Desktop slice.

A per-Turn position index is not part of this. It buys one thing — labelling a gap in Turns rather than in messages — and that is not worth a Storage table, a second pager, a cursor format and a protocol epoch. If a later consumer needs Turn counts, query the index on demand; do not page it to the client. Do not introduce materialized full-history snapshots, durable generations or leases, a second body protocol, or a custom byte scanner.

Acceptance

  • A break between the resident range and unloaded history is marked at its own position, not as a banner above the transcript.
  • Navigating tail to old Turn to intermediate Turn to tail never presents unloaded history as continuous.
  • Loading from a gap uses the existing range entry points and needs no new protocol operation.
  • The existing transcript scroll authority remains the only writer of scroll position.
  • Large-transcript performance does not regress from perf(desktop): bound transcript rendering and eliminate scroll LoAF #4206.

Related: #4206, #4244, #4295. Implementation attempt: #4560.

Codex helped analyze the architecture and draft the original correction. The 2026-09-04 correction above was verified against the current implementation.

Activity

  1. Sun-GLiang commented on Aug 29, 2026

    @Sun-GLiang
    Member

    Thank you for your invitation. I will continue to promote this work.

  2. Sun-GLiang commented on Aug 29, 2026

    @Sun-GLiang
    Member

    take

  3. Sun-GLiang commented on Aug 29, 2026

    @Sun-GLiang
    Member
    English

    Additional regression scenario: large first turn with an active live tail

    This was the condition under which the original bug was first observed and would be useful to preserve as an explicit acceptance/regression case.

    Given:

    • the transcript is larger than the bounded range budget;
    • the first turn is large enough to consume most or all of the historical resident range;
    • intermediate turns are durable but non-resident;
    • the final turn is still streaming through the active overlay.

    After navigating from the live tail to the first turn, the current Renderer projection can effectively become:

    resident durable range: Turn 1
    active overlay:         Turn N
    non-resident history:   Turn 2 ... Turn N-1
    

    Because logical turns are derived from resident messages plus the active overlay, the Renderer can make Turn 1 and Turn N appear adjacent. The intermediate durable turns are still present in storage, but their logical positions disappear along with their non-resident bodies.

    The unified logical turn-position model should instead preserve:

    Turn 1        loaded historical content
    Turn 2...N-1  unloaded logical positions / gaps
    Turn N        active overlay at the live tail
    

    Suggested regression coverage:

    1. Start with a transcript larger than twice the range budget, with a large first turn and an active final turn.
    2. Navigate from the live tail to the first turn while streaming continues.
    3. Verify that intermediate durable turns retain logical positions and that the first and active final turns never become logically adjacent.
    4. Navigate or scroll back to the live tail.
    5. Complete the active-overlay-to-durable handoff and verify stable identity/order, no duplication, no missing turns, and no scroll jump.

    This is part of the same paging/virtualization boundary problem, but explicitly covering it would protect the original discovery condition.

    简体中文

    补充回归场景:超大的首个 turn 与仍处于 active 状态的 live tail

    这是最初发现该 bug 时的触发条件,建议作为明确的验收和回归测试场景保留下来。

    条件如下:

    • transcript 大于有界 range budget;
    • 第一个 turn 很大,占用历史驻留范围的大部分或全部预算;
    • 中间 turns 已持久化,但当前未驻留;
    • 最后一个 turn 仍通过 active overlay 流式生成。

    从 live tail 跳转到第一个 turn 后,Renderer 当前的投影实际上可能变成:

    驻留的 durable range:Turn 1
    active overlay:       Turn N
    未驻留历史:           Turn 2 ... Turn N-1
    

    由于逻辑 turns 是从驻留消息与 active overlay 推导的,Turn 1 和 Turn N 可能被错误地表现为相邻。中间 turns 仍存在于 storage,但其逻辑位置会随着未驻留正文一起从 Renderer 中消失。

    统一后的逻辑 turn-position 模型应当保留:

    Turn 1        已加载的历史正文
    Turn 2...N-1  未加载的逻辑位置 / gaps
    Turn N        位于 live tail 的 active overlay
    

    建议测试:在流式生成期间跳转到超大的首个 turn,验证中间 turn 的逻辑位置始终存在;然后返回 live tail 并完成 active-overlay 到 durable message 的交接,验证顺序与身份稳定、不重复、不缺失且滚动位置不跳动。

  4. Sun-GLiang commented on Aug 29, 2026

    @Sun-GLiang
    Member
    English

    Controlled Desktop E2E reproduction — rerun at 01369b0

    I reran the same large-first-turn + active-live-tail experiment at 01369b08248d8b66b6c5a1a6204f9683397a10a1 (fix(desktop): simplify partial history notice (#4140)). All other conditions were kept the same.

    The run used an isolated E2E user-data directory and scratch SQLite workspace for every launch; it did not read or modify a developer database. The fixture Session was bound to the fixture Connection through the current llmConnectionId, so the result was not obscured by the legacy-account health banner.

    The fixture constructed this durable transcript:

    sequence 0-1: Turn 1 request + assistant plan (> 512 KiB)
    sequence 2-3: completed Turn 2 request + reply
    sequence 4-5: completed Turn 3 request + reply
    sequence 6:   Turn 4 request
    

    It then loaded the durable range around sequence 0 and supplied only the Turn 4 assistant response through the active overlay. The Runtime snapshot identified Turn 4 as the running root turn with one active text stream.

    Observed state:

    durable watermark:          6
    resident durable sequences: [0, 1]
    hasNewer:                   true
    active overlay:             [Turn 4 assistant]
    
    durable turn index:         [Turn 1, Turn 2, Turn 3, Turn 4]
    prompt residency:           [true, false, false, false]
    Renderer source count:      2
    Renderer logical turn ids:  [Turn 1, Turn 4]
    mounted turn ids:           [Turn 1, Turn 4]
    

    The result is unchanged from the earlier baseline: the rendered transcript contains the Turn 1 request/reply followed immediately by the Turn 4 assistant overlay. It does not contain the Turn 2 request/reply, the Turn 3 request/reply, or the durable Turn 4 request. Storage and the landmark index still contain all four Turns, but the non-resident Turns have no logical positions in the Renderer virtualizer.

    At this commit, the partial-history UI is visibly simpler: the page shows only “正在查看较早的消息” and “返回最新消息”. The legacy “需要选择账号” banner is absent. This improves the notice but does not change the sparse Renderer projection or restore a continuous logical transcript.

    The focused Playwright case passed in two independent launches, each with a fresh isolated database and a different generated Host/session identity. The existing transcript replica/range-store verification also remained green (20/20 tests). All temporary fixture hooks and the temporary spec were removed after capture, and the worktree was restored without a commit.

    As before, this is controlled state injection at the DesktopTranscriptReplica preparation boundary. It exercises the production Desktop storage index, transcript range store, Renderer, prompt rail, and turn virtualizer, while deliberately not claiming to reproduce every detail of a natural provider-stream lifecycle. The active-overlay condition is established by the replica/runtime probe; this injected seed does not set the final Turn's data-live-streaming DOM attribute at this commit.

    Controlled Desktop E2E rerun at 01369b0 showing Turn 1 adjacent to the Turn 4 active overlay
    简体中文

    受控 Desktop E2E 复现实验——基于 01369b0 重跑

    我在 01369b08248d8b66b6c5a1a6204f9683397a10a1(fix(desktop): simplify partial history notice (#4140))上重新运行了同一个“超大首个 turn + 仍处于 active 状态的 live tail”实验,其他条件保持不变。

    每次启动都使用独立的 E2E user-data 目录和临时 SQLite workspace,没有读取或修改开发者数据库。Fixture Session 通过当前的 llmConnectionId 绑定到 fixture Connection,因此结果没有受到旧版账号健康提示条的干扰。

    Fixture 构造了以下持久化 transcript:

    sequence 0-1:Turn 1 请求 + assistant plan(> 512 KiB)
    sequence 2-3:已完成的 Turn 2 请求 + 回复
    sequence 4-5:已完成的 Turn 3 请求 + 回复
    sequence 6:  Turn 4 请求
    

    随后,实验围绕 sequence 0 加载 durable range,并只通过 active overlay 提供 Turn 4 的 assistant 回复。Runtime snapshot 将 Turn 4 标记为正在运行的 root turn,并包含一个 active text stream。

    观察到的状态:

    durable watermark:          6
    驻留 durable sequences:     [0, 1]
    hasNewer:                   true
    active overlay:             [Turn 4 assistant]
    
    durable turn index:         [Turn 1, Turn 2, Turn 3, Turn 4]
    prompt residency:           [true, false, false, false]
    Renderer source count:      2
    Renderer logical turn ids:  [Turn 1, Turn 4]
    mounted turn ids:           [Turn 1, Turn 4]
    

    结果与先前基线一致:渲染后的 transcript 中,Turn 1 的请求/回复之后直接出现 Turn 4 的 assistant overlay。其中没有 Turn 2 的请求/回复、Turn 3 的请求/回复,也没有已持久化的 Turn 4 请求。Storage 和 landmark index 中仍然存在全部四个 Turns,但未驻留 Turns 在 Renderer virtualizer 中没有逻辑位置。

    在这个 commit 上,partial-history UI 明显更简洁:页面只显示“正在查看较早的消息”和“返回最新消息”;旧的“需要选择账号”提示条没有出现。这改善了提示方式,但没有改变稀疏的 Renderer 投影,也没有恢复连续的逻辑 transcript。

    聚焦的 Playwright 用例在两次独立启动中均通过;每次都使用全新的隔离数据库和不同的动态 Host/session identity。现有 transcript replica/range-store 验证也保持通过(20/20 tests)。截图完成后,所有临时 fixture hooks 和临时 spec 均已移除,worktree 已恢复且没有提交。

    与之前相同,本次复现是在 DesktopTranscriptReplica 准备边界注入受控状态。它覆盖了生产实现中的 Desktop storage index、transcript range store、Renderer、prompt rail 和 turn virtualizer,但不声称复现自然 provider streaming 生命周期的每一个细节。Active overlay 条件由 replica/runtime probe 确认;在此 commit 上,这个注入 seed 不会给最终 Turn 设置 data-live-streaming DOM 属性。

    基于 01369b0 的受控 Desktop E2E 重跑:Turn 1 与 Turn 4 active overlay 直接相邻
  5. Sun-GLiang commented on Aug 30, 2026

    @Sun-GLiang
    Member

    I reproduced the user-visible symptom with a real previously persisted Desktop session.

    Observed behavior

    Opening the existing session 描述一下这个issue。 shows only the final small segment of the conversation (the last tool calls and final answer). The earlier prompts and answers appear to be gone from the transcript, while the prompt rail still shows landmarks for the earlier prompts.

    This looks like Renderer-side history loss, but the durable transcript is intact:

    • 87 durable records are still present, with a complete sequence range from 0 through 86.
    • The stored transcript is about 208,052 bytes.
    • The first prompt and its answer, as well as the later intermediate prompts, are all still present in storage.
    • The visible UI segment corresponds to the final records near sequence 63–83.

    So this is not deletion or storage corruption. The missing content is outside the Renderer’s current resident transcript range.

    Important size-boundary detail

    This session is smaller than the 512 KiB transcript page/range limit, but it still reproduces the problem because the initial subscription bootstrap is separately limited to 16 KiB:

    SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES = 16 * 1024;
    SESSION_TRANSCRIPT_PAGE_MAX_BYTES = 512 * 1024;

    On open, DesktopTranscriptReplica.prepare() installs only the durable bootstrap and records that older content exists. The Renderer range store then exposes only those resident messages to ChatView, so the turn virtualizer can build logical positions only for that tail range.

    Navigating to an unloaded prompt follows the same architectural path described in this issue: DesktopTranscriptReplica.#replaceWithRange() clears the current durable range, installs the target range, and publishes the old sequences as evicted. The Renderer deletes those sequences from its range store. Consequently, paging changes the set of logical turns instead of preserving one continuous transcript with unloaded gaps.

    The prompt rail remains populated because it comes from the separate transcript-wide landmark index (listTurnLandmarks), which explains the confusing combination of multiple rail ticks and only one visible transcript segment.

    Conclusion

    This is a direct real-session reproduction of #4123. The specific initial-open trigger here is the 16 KiB bootstrap boundary rather than the 512 KiB page boundary, but the root cause is the same: non-resident durable turns have no stable logical positions in Renderer state.

    The desired fix in this issue—keeping a lightweight transcript-wide turn-position index, representing unloaded ranges as gaps/spacers, and paging message bodies around the viewport—would address this case as well.

  6. changed the title [-]feat(desktop): unify sparse transcript paging with turn virtualization[/-] [+]feat(desktop): preserve transcript continuity across bounded ranges[/+] on Aug 31, 2026
  7. Astro-Han commented on Aug 31, 2026

    @Astro-Han
    ContributorAuthor

    I rewrote this issue after reviewing the architecture established by #4206 and #4244. The previous virtualizer and PR1-PR4 plan is superseded.

    Please pause #4258 and #4295 while I confirm the smallest Desktop position contract. Continuing the old plan would add substantial backend machinery before the product consumer and interaction are settled.

    I'm sorry that the previous issue sent this work in the wrong direction. More context is in #4295 (comment).

    Codex helped with the architecture review and wording. I verified the conclusion against the current code and recent performance PRs.

  8. Sun-GLiang commented on Aug 31, 2026

    @Sun-GLiang
    Member

    Thanks for rewriting the issue. The revised direction is clear. I’ll keep #4295 as a draft and pause for now. Please let me know when the position contract is confirmed and I can proceed with the revised implementation.

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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions