| 1 | # Transcript scroll and history contract |
| 2 | |
| 3 | [中文](TRANSCRIPT_SCROLL_CONTRACT.zh-CN.md) |
| 4 | |
| 5 | ## Scope |
| 6 | |
| 7 | The transcript (`../desktop/frontend/src/components/Transcript.tsx`) renders |
| 8 | through `ChatSource` and `ChatScrollController` in natural document flow. There |
| 9 | is one implementation for local and remote sessions. The retired window |
| 10 | adapter, measurement ledger, geometry-revision loop and logical selection are |
| 11 | not coming back: do not reintroduce a second rendering stack, a nested virtual |
| 12 | vertical scroller, or a platform-specific scroll compensation. |
| 13 | |
| 14 | Keep these contracts when touching anything that can move the transcript |
| 15 | viewport or change which history is resident. |
| 16 | |
| 17 | ## Identity and rendering |
| 18 | |
| 19 | - **Stable node keys**: node and block keys derive from message, turn and tool |
| 20 | identities, never array positions. Prepend, settlement and content patches |
| 21 | must not rename a mounted node. |
| 22 | - **Unchanged nodes keep their object**: streaming and settlement update the |
| 23 | same assistant host; an unchanged node or order snapshot retains its |
| 24 | reference so React does not remount it. |
| 25 | - **Markdown block identity** comes from the parse: each block carries a key |
| 26 | (top-level index within one parse) and a content fingerprint stamped by the |
| 27 | parse that produced it. The render path keeps the previous AST object when |
| 28 | both match, which is what preserves native selection and code disclosure |
| 29 | across stream publications. Do not compare serialized trees on the render |
| 30 | path — that cost is what the fingerprint replaced. |
| 31 | - **Natural flow**: Markdown, tables and loaded history use document flow. |
| 32 | Parsing may be lazy and content may be fetched on demand, but the transcript |
| 33 | must not create a nested virtual vertical scroller. Collapsed process/tool |
| 34 | bodies are mounted on demand. |
| 35 | - **Business state lives in its owner**: the controller and the history stores |
| 36 | own state; `ChatSource` is a reconstructable view projection. Structural |
| 37 | changes batch in microtasks. |
| 38 | |
| 39 | ## Single writer |
| 40 | |
| 41 | - Only `../desktop/frontend/src/lib/transcriptViewportWriter.ts` may mutate the |
| 42 | transcript's native scroll position. `ChatScrollController` owns programmatic |
| 43 | follow, reader anchoring and navigation; everything else submits to it. |
| 44 | `../desktop/frontend/scripts/check-single-scroll-writer.mjs` must reject any |
| 45 | bypass. |
| 46 | - Native input is never synthesized or prevented to keep the tail pinned. A |
| 47 | small upward reader movement releases follow, including inside the bottom |
| 48 | threshold. |
| 49 | - Prepend, resize and page replacement preserve a stable node plus a viewport |
| 50 | offset. |
| 51 | |
| 52 | ## Bounded reading window |
| 53 | |
| 54 | History is a bounded window, not an ever-growing list. |
| 55 | |
| 56 | - The resident store keeps a small number of adjacent pages per session |
| 57 | (`windowMaxPages`, default 3, over 32-message pages) **including the active |
| 58 | session**. Paging past that budget reclaims a page from the end the reader is |
| 59 | moving away from and reports the item ids the caller must drop; a caller that |
| 60 | ignores them renders rows the store has already released. |
| 61 | - **Pins protect a session's identity and its live edge, not an unbounded |
| 62 | record set.** A running or visible session still cannot be evicted, but its |
| 63 | history is subject to the same page budget as any other. |
| 64 | - Reclaiming is not deletion. The persisted session stays authoritative and the |
| 65 | reclaimed direction stays reachable through its cursor, so every message is |
| 66 | still findable, searchable and exportable. Do not treat "all history is |
| 67 | mounted" as a correctness property; assert reachability and bounded residency |
| 68 | instead. |
| 69 | - Paging is bidirectional (`loadOlder` / `loadNewer`). A binding that reports no |
| 70 | newer cursor keeps its forward paging rather than being asked to simulate one |
| 71 | through full downloads. |
| 72 | - Window cursors pin a fixed snapshot. Appends keep a cursor valid; a storage |
| 73 | replacement or projection rebuild answers the typed `stale_cursor`, and a |
| 74 | cursor the server cannot read is that same typed answer rather than a |
| 75 | transport error. A client re-anchors at most once and keeps its current page |
| 76 | with a retry affordance after a second failure. |
| 77 | - The turn rail describes loaded turns only, as specified in |
| 78 | [Transcript v2](TRANSCRIPT_V2.md). Its marks track the resident window; it does |
| 79 | not enumerate unloaded history. |
| 80 | - Jumps to unloaded history (for example, a canonical search hit) resolve through |
| 81 | the history index and request the page around the target. They never walk |
| 82 | pages from the newest position. |
| 83 | |
| 84 | ## Routing |
| 85 | |
| 86 | - History reads route by the tab's **binding identity**, resolved before the |
| 87 | request from the tab metadata the controller already loads. A failed local |
| 88 | call must never be answered by a remote service holding a different session. |
| 89 | - Chat requires negotiated `transcript-v2` on Desktop and Serve. An older |
| 90 | service receives an upgrade error, without legacy chat fallback. Permission, |
| 91 | corruption and network errors do not trigger a protocol downgrade. |
| 92 | |
| 93 | ## Generation fence |
| 94 | |
| 95 | - Session or surface replacement increments the generation. Every delayed |
| 96 | measurement, timer, animation-frame callback and write request carries that |
| 97 | generation; stale work performs zero writes. |
| 98 | - Async paging owns a source-session request identity; navigation owns the |
| 99 | generation plus its interaction revision from request through the positioned |
| 100 | terminal state. Native takeover cancels navigation, not a valid source data |
| 101 | load. An old completion or `finally` may release only its identical request. |
| 102 | - A response from a replaced session must not advance coverage or mutate |
| 103 | another tab's state. |
| 104 | |
| 105 | ## Budgets |
| 106 | |
| 107 | - Per-renderer history body cache 32 MiB and parsed-markdown cache 16 MiB are |
| 108 | admission budgets for rebuildable data. They are not a bound on the whole |
| 109 | Electron process or on model-execution memory. |
| 110 | - String sizes are counted as resident representation; media is counted by |
| 111 | decoded size. Network bytes are not heap bytes. |
| 112 | - Reclaiming a page withdraws the body requests, parse tasks, DOM and object |
| 113 | URLs that belong to it. |
| 114 | - Text length and element counts that exceed a preview budget degrade to a |
| 115 | bounded preview with an explicit detail path. Do not silently truncate a |
| 116 | copy or export: an explicit full-content action or a streamed file export |
| 117 | carries the whole value. |
| 118 | |
| 119 | ## Deterministic behaviour |
| 120 | |
| 121 | - Scroll logic goes through the same injectable clock the controller uses |
| 122 | (`requestAnimationFrame`, `Date.now`, timer functions). No real sleeps and no |
| 123 | hidden retry clocks. |
| 124 | - A transaction whose requested offset has already landed may commit as a |
| 125 | no-op, but must not assign `scrollTop` again. |
| 126 | - **Race tests are mandatory**: any scroll or paging behaviour change ships |
| 127 | with a deterministic event sequence in |
| 128 | `../desktop/frontend/src/__tests__/`, and `pnpm test:transcript` runs before |
| 129 | committing transcript changes. |
| 130 |