From 1dd3e60f5043420909d9fd9b3f59607f03ca906c Mon Sep 17 00:00:00 2001
From: 07akioni <07akioni2@gmail.com>
Date: Mon, 31 Aug 2026 17:22:25 +0800
Subject: [PATCH 01/26] fix(web): make streaming code fences incremental
---
...20-web-streaming-fence-highlight.i18n.yaml | 4 +-
...026-08-20-web-streaming-fence-highlight.md | 17 +-
...-08-20-web-streaming-fence-highlight.zh.md | 17 +-
...8-25-loaded-turn-chat-navigation.i18n.yaml | 4 +-
.../2026-08-25-loaded-turn-chat-navigation.md | 4 +-
...26-08-25-loaded-turn-chat-navigation.zh.md | 4 +-
packages/client/ui-chat/README.i18n.yaml | 4 +-
packages/client/ui-chat/README.md | 8 +
packages/client/ui-chat/README.zh.md | 8 +
.../ui-chat/src/client/chat/ChatView.tsx | 8 +-
.../ui-chat/tests/chat-view.client.spec.tsx | 52 ++++
.../client/ui-primitives/README.i18n.yaml | 4 +-
packages/client/ui-primitives/README.md | 5 +-
packages/client/ui-primitives/README.zh.md | 5 +-
.../ui-primitives/src/markdown/CodeBlock.tsx | 94 +++++--
.../src/markdown/MarkdownText.tsx | 6 +-
.../ui-primitives/src/markdown/highlight.ts | 92 ++++---
.../ui-primitives/src/markdown/incremental.ts | 256 +++++++++++++++++-
.../markdown-incremental.client.spec.tsx | 121 +++++++++
.../streaming-code-block.client.spec.tsx | 42 ++-
20 files changed, 649 insertions(+), 106 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml
index 2139112dec..bd978d1920 100644
--- a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md
-2026-08-20-web-streaming-fence-highlight.md: ccc961da1febba3611e3e558087b586c6f1f474b
-2026-08-20-web-streaming-fence-highlight.zh.md: e5d29545bf659ed572d052f1212d68d344d49b5a
+2026-08-20-web-streaming-fence-highlight.md: aa44a8c7ea91ff4913d92449d62a6ebf9406e922
+2026-08-20-web-streaming-fence-highlight.zh.md: 88bae36928ac8d00960e529addeb4f30240e3579
diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md
index ccc961da1f..aa44a8c7ea 100644
--- a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md
+++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md
@@ -10,17 +10,18 @@ While a reply streamed, `MarkdownText` stripped the fence language before `CodeB
## Decision
-Streaming fences highlight incrementally through grammar-state resumption; the settled arm is unchanged.
+Streaming fences parse, tokenize, and reconcile from retained frontiers; the settled output is unchanged.
-- **`StreamingHighlightSession`** (`packages/client/ui-primitives/src/markdown/highlight.ts`) exploits that TextMate tokenization is line-based and forward-only: a line's tokens depend only on its own text and the grammar state entering it, so appended text never changes a completed line's tokens. The session caches completed lines' spans plus shiki's `GrammarState` after them (`getLastGrammarState`), and each update tokenizes newly completed text via `codeToTokensBase(…, { grammarState })` plus the still-growing last line. Per-chunk cost excludes the completed prefix; the result is token-identical to a from-scratch tokenization. Non-append input and a resolved-grammar change reset the cache and re-tokenize fully. Each run carries the style shiki's HTML arm would assign it — the css-variables color plus the markup font-style bits the theme lets through (bold/italic/underline; markdown fences carry them); whitespace-only runs fold into their following token as shiki's default `mergeWhitespaces` does (its underlined/struck-whitespace exemption cannot occur under this theme, whose only underline rule styles inline-link scopes that tokenize spaced text as one run); and a CRLF cut never leaks its `\r` into the last completed line, matching shiki's own line splitting — so the streaming spans and the settled `codeToHtml` swap render one identical span tree.
-- **`CodeBlock`** gains a `streaming` prop: it renders the session's spans as a `pre.shiki.css-variables` React tree with the same attributes shiki's HTML emits, holds the session and per-line elements in refs, and reuses a retained line's element identity so React leaves that line's DOM untouched. Unknown or absent languages keep the identical-geometry plain arm; a lazy grammar renders plain until it registers, then the existing `useSyncExternalStore` load signal re-renders into highlight — one plain→highlighted transition, no flicker back.
-- **`render.tsx`** passes `lang` and `context.streaming` to fences. Wrong-grammar transients are structurally impossible: a fence whose info string is still mid-chunk (`` ```py `` completing to `` ```python ``) has no content yet — content only exists after the info line's newline, which finalizes the language — and the empty-value fence keeps the stock `
`. The streaming CodeBlock instance survives every chunk because streaming render keys are source offsets. `` ```math `` fences and TeX stay literal until the settled pass; the language banner shows the fence language during streaming.
+- **`IncrementalMarkdownParser`** (`packages/client/ui-primitives/src/markdown/incremental.ts`) recognizes a parser-confirmed final unclosed top-level fence after the ordinary tail parse. Completed content remains in the retained `code` node; only the last completed line and current partial line pass through the caller's GFM grammar, preserving its newline, indentation, CRLF, and value semantics without re-parsing the fence prefix. A closing delimiter, non-append input, nested/container fence, or ambiguous reconstruction returns to the ordinary tail parse.
+- **`StreamingHighlightSession`** (`packages/client/ui-primitives/src/markdown/highlight.ts`) exploits that TextMate tokenization is line-based and forward-only: a line's tokens depend only on its own text and the grammar state entering it, so appended text never changes a completed line's tokens. The session caches completed lines' spans plus shiki's `GrammarState` after them (`getLastGrammarState`); `updateFrame` publishes only newly completed lines plus the still-growing last line, while the compatibility `update` method materializes the complete result. Per-chunk tokenization excludes the completed prefix; the result is token-identical to a from-scratch tokenization. Non-append input and a resolved-grammar change reset the cache and re-tokenize fully. Each run carries the style shiki's HTML arm would assign it — the css-variables color plus the markup font-style bits the theme lets through (bold/italic/underline; markdown fences carry them); whitespace-only runs fold into their following token as shiki's default `mergeWhitespaces` does (its underlined/struck-whitespace exemption cannot occur under this theme, whose only underline rule styles inline-link scopes that tokenize spaced text as one run); and a CRLF cut never leaks its `\r` into the last completed line, matching shiki's own line splitting.
+- **`CodeBlock`** renders the delta frames as a `pre.shiki.css-variables` React tree with the same attributes and token spans shiki's HTML emits. Completed lines seal into fixed-size React fragments; later updates reuse those fragment elements and reconcile only a bounded pending group plus the mutable tail. Unknown or absent languages keep the identical-geometry plain arm; a lazy grammar renders plain until it registers, then the existing `useSyncExternalStore` load signal re-renders into highlight — one plain→highlighted transition, no flicker back. Group size is an internal reconciliation unit, not a deployment policy or content limit.
+- **`render.tsx` and `MarkdownText`** pass `lang` and `context.streaming` to fences and key both streaming and settled top-level blocks by source offset. Wrong-grammar transients are structurally impossible: a fence whose info string is still mid-chunk (`` ```py `` completing to `` ```python ``) has no content yet — content only exists after the info line's newline, which finalizes the language — and the empty-value fence keeps the stock ``. `` ```math `` fences and TeX stay literal until the settled pass; the language banner shows the fence language during streaming.
-The settle swap re-renders through `highlightToHtml`: same tokens, same span tree, so the swap is visually invisible and never touches the code content.
+The final full-document parse still resolves document-wide references and math. When that parse produces the same fence code and language, the source-offset key preserves its `CodeBlock` instance and the component reuses the complete streamed React tree; cold settled fences continue through `highlightToHtml`.
## Testing
-Package tests cover incremental/from-scratch equivalence across multiline grammar state, blank lines, CRLF, and markup styles; cache identity and reset/lazy paths; streaming/settled token-tree parity; DOM retention; and plain or math fallbacks. The assembled Web browser snapshot boots the real Web composition, streams a TypeScript fence through the Host and SSE path, pauses the deterministic LLM adapter while the reply is still active, and snapshots Chromium's Shiki token tree before verifying that settlement preserves it. The `tests/fixtures/markdown-dom/*.streaming.txt` fixtures pin the intentional streaming divergence from their react-markdown origin: the Shiki span tree and visible language banner replace the plain arm.
+Package tests bound the grammar input accumulated across 800 open-fence lines, compare each incremental result with a full parse, and cover indented delimiters, CRLF split across chunks, closure fallback, and non-append reset. Highlighter tests cover incremental/from-scratch equivalence across multiline grammar state, blank lines, CRLF, and markup styles; delta identity and reset/lazy paths; fixed-group DOM retention; streamed-to-settled DOM identity; and plain or math fallbacks. The assembled Web browser snapshot boots the real Web composition, streams a TypeScript fence through the Host and SSE path, pauses the deterministic LLM adapter while the reply is still active, and snapshots Chromium's Shiki token tree before verifying that settlement preserves it. The `tests/fixtures/markdown-dom/*.streaming.txt` fixtures pin the intentional streaming divergence from their react-markdown origin: the Shiki span tree and visible language banner replace the plain arm.
## Alternatives considered
@@ -28,10 +29,12 @@ Package tests cover incremental/from-scratch equivalence across multiline gramma
**Highlight only frozen (closed, settled-position) fences during streaming.** Bounded cost, but an unclosed fence pins the incremental parser's tail, so the actively growing fence — the one on screen — would stay plain until the reply finishes, failing the issue's "识别语言后即可增量高亮".
+**Keep only a fixed window of highlighted lines and turn the older prefix into plain text.** This bounds live token DOM and can reduce layout further, but changes already rendered content, complicates selection across the window boundary, and makes a tunable presentation policy part of `CodeBlock`. Retained parser, tokenizer, and React frontiers remove the avoidable repeated work without discarding colors; the complete token DOM remains an explicit limitation rather than a hidden semantic change.
+
**Move highlighting to a worker or async pass.** Rejected when shiki was adopted ([synchronous highlighting note](../process/2026-07-26-web-syntax-highlighting-shiki.md)); an async swap also reintroduces the plain→colored→plain flicker class this change must avoid.
**Build the settled HTML string incrementally and keep `dangerouslySetInnerHTML`.** Exact settled parity for free, but React replaces the whole `innerHTML` per chunk, so the browser re-parses and rebuilds every line's DOM each time — O(fence) DOM churn that forfeits the token-level win the session provides.
## Consequences
-Streaming code is readable as it arrives: tokens color as soon as the language is known, completed lines never re-tokenize or re-render, and the finalize swap is invisible for fences. The package owns a small mirror of shiki's HTML-arm conventions — the `pre` attributes and the whitespace fold — pinned by the arm-parity test, so a shiki upgrade that changes either fails loud there instead of drifting the two arms apart. The streaming DOM-parity fixtures pin Shiki span trees as an intentional divergence from their react-markdown origin. The still-growing last line re-tokenizes per chunk (bounded by one line), and a pathological single-line fence still degrades to full re-tokenization per chunk — the same degradation class the incremental block parser accepts for a single giant block.
+Streaming code is readable as it arrives: tokens color as soon as the language is known; completed top-level fence content neither re-parses nor re-tokenizes; sealed React groups keep their elements and DOM; and settlement preserves the highlighted tree. The package owns a small mirror of shiki's HTML-arm conventions — the `pre` attributes and the whitespace fold — pinned by the arm-parity test, so a shiki upgrade that changes either fails loud there instead of drifting the two arms apart. The streaming DOM-parity fixtures pin Shiki span trees as an intentional divergence from their react-markdown origin. The retained DOM still grows with final token count, so browser style and layout work is not length-independent. Nested/container fences use the ordinary tail parser, and the still-growing last line re-tokenizes per chunk; a pathological single-line fence therefore remains the worst case.
diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md
index e5d29545bf..88bae36928 100644
--- a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md
@@ -10,17 +10,18 @@ Status: implemented
## Decision
-流式围栏通过 grammar state 续接实现增量高亮;定稿臂保持不变。
+流式围栏从保留的解析、tokenize 与 reconcile 前沿继续推进;定稿输出保持不变。
-- **`StreamingHighlightSession`**(`packages/client/ui-primitives/src/markdown/highlight.ts`)利用 TextMate tokenize 按行、且只向前推进的性质:一行的 token 只取决于该行文本与进入该行时的 grammar state,因此追加的文本永远不会改变已完成行的 token。会话缓存已完成行的 span 以及其后的 shiki `GrammarState`(`getLastGrammarState`),每次更新通过 `codeToTokensBase(…, { grammarState })` tokenize 新完成的文本,外加仍在增长的最后一行。每分片成本不包含已完成的前缀;结果与从头 tokenize 逐 token 一致。非追加输入与解析后语法变化会重置缓存并完整重新 tokenize。每个 run 携带 shiki HTML 臂会赋予它的样式——css-variables 颜色加上主题放行的 markup 字体位(bold/italic/underline;markdown 围栏会携带它们);纯空白 run 并入其后的 token,与 shiki 默认的 `mergeWhitespaces` 一致(其对带下划线/删除线空白的豁免在该主题下不可能出现:主题唯一的 underline 规则作用于 inline-link scope,其含空格文本整体成一个 run);CRLF 切割点的 `\r` 绝不进入最后一个已完成行,与 shiki 自身的行切分一致——因此流式 span 与定稿 `codeToHtml` 换入的 span 树完全一致。
-- **`CodeBlock`** 新增 `streaming` prop:把会话的 span 渲染为带有 shiki HTML 同款属性的 `pre.shiki.css-variables` React 树,用 ref 持有会话与逐行元素,并复用保留行的元素标识,让 React 完全不触碰该行的 DOM。未知或缺失语言保持几何一致的纯文本臂;懒加载语法在注册前渲染纯文本,注册后由既有的 `useSyncExternalStore` 加载信号触发重渲染进入高亮——只有一次纯文本→高亮的转换,不会闪回。
-- **`render.tsx`** 向围栏传递 `lang` 与 `context.streaming`。错误语法的瞬时着色在结构上不可能出现:info string 尚在分片中途的围栏(`` ```py `` 补全为 `` ```python ``)还没有内容——内容只在 info 行的换行之后才存在,而该换行恰恰定格了语言——空值围栏保持原生 ``。流式渲染 key 是源偏移,围栏的 CodeBlock 实例因此跨分片存活。`` ```math `` 围栏与 TeX 在定稿前保持字面量;语言横幅在流式期间显示围栏语言。
+- **`IncrementalMarkdownParser`**(`packages/client/ui-primitives/src/markdown/incremental.ts`)会在普通尾部解析后识别经 parser 确认、位于末尾且未闭合的顶层 fence。已完成内容保留在既有 `code` node 中;只有最后一个已完成行与当前未完成行再次进入调用方的 GFM grammar,因此无需重新解析 fence 前缀,也能保留其换行、缩进、CRLF 与 value 语义。出现闭合分隔符、非追加输入、嵌套/容器内 fence 或无法明确重建时,会回到普通尾部解析。
+- **`StreamingHighlightSession`**(`packages/client/ui-primitives/src/markdown/highlight.ts`)利用 TextMate tokenize 按行、且只向前推进的性质:一行的 token 只取决于该行文本与进入该行时的 grammar state,因此追加的文本永远不会改变已完成行的 token。会话缓存已完成行的 span 以及其后的 shiki `GrammarState`(`getLastGrammarState`);`updateFrame` 只发布新完成行与仍在增长的最后一行,兼容方法 `update` 则物化完整结果。每分片 tokenize 成本不包含已完成的前缀;结果与从头 tokenize 逐 token 一致。非追加输入与解析后语法变化会重置缓存并完整重新 tokenize。每个 run 携带 shiki HTML 臂会赋予它的样式——css-variables 颜色加上主题放行的 markup 字体位(bold/italic/underline;markdown 围栏会携带它们);纯空白 run 并入其后的 token,与 shiki 默认的 `mergeWhitespaces` 一致(其对带下划线/删除线空白的豁免在该主题下不可能出现:主题唯一的 underline 规则作用于 inline-link scope,其含空格文本整体成一个 run);CRLF 切割点的 `\r` 绝不进入最后一个已完成行,与 shiki 自身的行切分一致。
+- **`CodeBlock`** 把增量 frame 渲染为带有 shiki HTML 同款属性与 token span 的 `pre.shiki.css-variables` React 树。已完成行会封入固定大小的 React fragment;后续更新复用这些 fragment element,只 reconcile 一个有界的待完成分组与可变尾部。未知或缺失语言保持几何一致的纯文本臂;懒加载语法在注册前渲染纯文本,注册后由既有的 `useSyncExternalStore` 加载信号触发重渲染进入高亮——只有一次纯文本→高亮的转换,不会闪回。分组大小只是内部 reconcile 单元,不是部署策略或内容上限。
+- **`render.tsx` 与 `MarkdownText`** 向围栏传递 `lang` 与 `context.streaming`,并让流式和定稿的顶层 block 都按源偏移设置 key。错误语法的瞬时着色在结构上不可能出现:info string 尚在分片中途的围栏(`` ```py `` 补全为 `` ```python ``)还没有内容——内容只在 info 行的换行之后才存在,而该换行恰恰定格了语言——空值围栏保持原生 ``。`` ```math `` 围栏与 TeX 在定稿前保持字面量;语言横幅在流式期间显示围栏语言。
-定稿切换经 `highlightToHtml` 重渲染:token 相同、span 树相同,切换在视觉上不可见,也绝不触碰代码内容。
+最终的全量文档解析仍会解决跨文档引用与数学语法。当该解析产生相同的 fence 代码与语言时,源偏移 key 会保留其 `CodeBlock` 实例,组件则复用完整的流式 React 树;冷启动的定稿 fence 继续使用 `highlightToHtml`。
## Testing
-包测试覆盖跨多行 grammar state、空行、CRLF 与 markup 样式的增量/从头等价性,缓存标识与重置/懒加载路径,流式/定稿 token 树一致性,DOM 保留,以及纯文本和 math 回退。组装后的 Web 浏览器快照会启动真实 Web 组合,让 TypeScript 围栏经过 Host 与 SSE 路径流式传输,在回复仍活跃时暂停确定性 LLM 适配器并对 Chromium 中的 Shiki token 树做快照,然后验证定稿保留该 token 树。`tests/fixtures/markdown-dom/*.streaming.txt` fixture 锁定相对 react-markdown 来源的一项有意分叉:Shiki span 树与可见语言横幅取代纯文本臂。
+包测试会约束 800 行未闭合 fence 的累计 grammar 输入量、逐次比较增量结果与全量解析,并覆盖缩进分隔符、跨分片 CRLF、闭合回退与非追加重置。高亮测试覆盖跨多行 grammar state、空行、CRLF 与 markup 样式的增量/从头等价性,delta 标识与重置/懒加载路径,固定分组的 DOM 保留,从流式到定稿的 DOM 标识,以及纯文本和 math 回退。组装后的 Web 浏览器快照会启动真实 Web 组合,让 TypeScript 围栏经过 Host 与 SSE 路径流式传输,在回复仍活跃时暂停确定性 LLM 适配器并对 Chromium 中的 Shiki token 树做快照,然后验证定稿保留该 token 树。`tests/fixtures/markdown-dom/*.streaming.txt` fixture 锁定相对 react-markdown 来源的一项有意分叉:Shiki span 树与可见语言横幅取代纯文本臂。
## Alternatives considered
@@ -28,10 +29,12 @@ Status: implemented
**流式期间只高亮已冻结(闭合且位置定格)的围栏。** 成本有界,但未闭合围栏会钉住增量解析器的尾部,于是正在增长的围栏——屏幕上的那个——要等回复结束才高亮,不满足 issue 的"识别语言后即可增量高亮"。
+**只保留固定窗口内的高亮行,并把更早的前缀转成纯文本。** 这能限制流式 token DOM 并进一步降低布局成本,但会改变已经渲染的内容、让跨窗口边界的选择更复杂,还会把可调的展示策略塞进 `CodeBlock`。保留解析、tokenize 与 React 前沿可以在不丢颜色的情况下消除可避免的重复工作;完整 token DOM 被明确记录为限制,而不是隐藏的语义变化。
+
**把高亮移到 worker 或异步流程。** 采纳 shiki 时已否决([同步高亮笔记](../process/2026-07-26-web-syntax-highlighting-shiki.zh.md));异步换入还会重新引入本变更必须避免的纯文本→彩色→纯文本闪烁类问题。
**增量拼接定稿 HTML 字符串并继续使用 `dangerouslySetInnerHTML`。** 白得定稿一致性,但 React 每个分片都会整体替换 `innerHTML`,浏览器每次重新解析并重建所有行的 DOM——O(围栏) 的 DOM 翻搅,抵消了会话在 token 层的收益。
## Consequences
-流式代码随到达即可读:语言一经识别 token 即着色,已完成行绝不重新 tokenize 或重渲染,定稿切换对围栏而言不可见。该包持有一小份 shiki HTML 臂约定的镜像——`pre` 属性与空白折叠——由双臂一致性测试锁定,shiki 升级若改变任一处会在该测试处响亮失败,而不是让两臂悄然漂移。流式 DOM 一致性 fixture 锁定 Shiki span 树,这是相对其 react-markdown 来源的一项有意分叉。仍在增长的最后一行每分片重新 tokenize(以一行为界);病态的单行超长围栏仍退化为每分片完整重新 tokenize——与增量块解析器对单个巨型块接受的是同一退化类。
+流式代码随到达即可读:语言一经识别 token 即着色;顶层 fence 的已完成内容不再重新解析或 tokenize;封存的 React 分组保留其 element 与 DOM;定稿也会保留高亮树。该包持有一小份 shiki HTML 臂约定的镜像——`pre` 属性与空白折叠——由双臂一致性测试锁定,shiki 升级若改变任一处会在该测试处响亮失败,而不是让两臂悄然漂移。流式 DOM 一致性 fixture 锁定 Shiki span 树,这是相对其 react-markdown 来源的一项有意分叉。保留的 DOM 仍随最终 token 数增长,因此浏览器 style 与 layout 工作量并非与长度无关。嵌套/容器内 fence 使用普通尾部解析器,仍在增长的最后一行则会每分片重新 tokenize;病态的单行超长 fence 因而仍是最坏情况。
diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml
index 0ba01f1eeb..2fd847ed20 100644
--- a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md
-2026-08-25-loaded-turn-chat-navigation.md: 5d9d93b07f7a8c527bf7376bf111c6a709afa4d1
-2026-08-25-loaded-turn-chat-navigation.zh.md: 21dba024710b306848fee9bc4fe1f16913c475b3
+2026-08-25-loaded-turn-chat-navigation.md: b01e64d23fbc87f61c0de5b32cdf293c8d0afa94
+2026-08-25-loaded-turn-chat-navigation.zh.md: 85397c4a33cbc4ff1a08d253845f0112fe9b051b
diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md
index 5d9d93b07f..b01e64d23f 100644
--- a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md
+++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md
@@ -18,7 +18,7 @@ The rail renders the complete loaded Turn set with a 10px natural interval and n
The rail sits against the scrollport's right edge and centers on the band the sticky composer leaves visible. That band is the scrollport's own height minus the seat's, so ConversationRoot publishes `--dsh-conversation-viewport-height` beside the `--dsh-composer-height` it already measures on the same element, and the rail centers on their difference instead of a viewport height that ignores the Session header.
-The active mark follows a reading line near the top of the shared Chat scrollport. A scroll frame resolves the owning Turn with one hit test at that line, falling back to a single row scan where layout cannot answer, so cost does not grow with the number of marks. Flow-height changes that move rows across the line without a scroll event resync through the existing column observer. Scroll updates are coalesced with `requestAnimationFrame`; reaching the bottom selects the final loaded Turn. Activating a mark computes the target node's position in the existing scroll coordinate system, moves that same scrollport, and records the resulting Chat scroll-restoration anchor.
+The active mark follows a reading line near the top of the shared Chat scrollport. A pinned frame selects the final loaded Turn from scroll distance before reading any row geometry; streaming and other observed height changes can therefore follow the floor without a hit test or scan. Away from the floor, a scroll frame resolves the owning Turn with one hit test at the reading line, falling back to a single row scan where layout cannot answer, so cost does not grow with the number of marks. Flow-height changes that move rows across the line without a scroll event resync through the existing column observer. Scroll updates are coalesced with `requestAnimationFrame`. Activating a mark computes the target node's position in the existing scroll coordinate system, moves that same scrollport, and records the resulting Chat scroll-restoration anchor.
Every Turn remains an accessible button even when dense marks visually overlap. The rail maps pointer height to the nearest loaded Turn, while keyboard focus and activation operate the individual buttons. Hover and focus show a compact prompt-and-response preview, the active mark is longer and darker, the rail is hidden when the Chat container is at most 900px wide, and reduced-motion preferences disable redistribution and mark-entry animation.
@@ -42,4 +42,4 @@ Desktop-width Chat views can jump among all currently loaded Turns and inspect a
## Testing
-Builder tests pin the accumulated projection, the bounded preview, and preview freshness under an in-place chunk update. Component tests pin the published items, accessible previews, scroll-coordinate jumps, DOM identity, and percentage redistribution after prepend. The long-interaction Chromium scenario pins the real paginated boundary, prompt completion after `Load earlier`, stable-mark movement, keyboard activation, active-state update, and the narrow-container hide. The multi-Turn recorded Web snapshot includes the navigation landmark and buttons.
+Builder tests pin the accumulated projection, the bounded preview, and preview freshness under an in-place chunk update. Component tests pin the published items, accessible previews, scroll-coordinate jumps, DOM identity, percentage redistribution after prepend, and a pinned `ResizeObserver` update that rejects every row-geometry read. The long-interaction Chromium scenario pins the real paginated boundary, prompt completion after `Load earlier`, stable-mark movement, keyboard activation, active-state update, and the narrow-container hide. The multi-Turn recorded Web snapshot includes the navigation landmark and buttons.
diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md
index 21dba02471..85397c4a33 100644
--- a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md
@@ -18,7 +18,7 @@ Chat snapshot 构建层为当前已加载且含可见 transcript node 的每个
轨道紧贴滚动视口右缘,并在粘性输入区之外的可见区间内垂直居中。该区间等于滚动视口自身高度减去输入区高度,因此 ConversationRoot 在同一元素上除已有的 `--dsh-composer-height` 外再发布 `--dsh-conversation-viewport-height`,轨道按两者之差居中,而不是按忽略 Session 头部的视口高度居中。
-活跃刻度跟随共享 Chat 滚动区顶部附近的阅读线。每个滚动帧用一次命中测试解析该行所属 Turn,布局无法作答时退化为一次行扫描,成本不随刻度数量增长。图片加载、工具卡展开等不产生滚动事件的高度变化,通过既有的 column observer 重新同步。滚动更新由 `requestAnimationFrame` 合并;到达底部时选择最后一个已加载 Turn。激活刻度会在现有滚动坐标系中计算目标 node 的位置,移动同一个滚动区,并记录由此产生的 Chat 滚动恢复锚点。
+活跃刻度跟随共享 Chat 滚动区顶部附近的阅读线。跟随底部的 frame 会先按滚动距离选中最后一个已加载 Turn,不读取任何行几何;流式输出及其他被 observer 捕获的高度变化因此无需命中测试或扫描即可追随底部。离开底部后,每个滚动 frame 用一次命中测试解析阅读线所属 Turn,布局无法作答时退化为一次行扫描,成本不随刻度数量增长。不产生滚动事件却让行跨过阅读线的高度变化通过既有的 column observer 重新同步。滚动更新由 `requestAnimationFrame` 合并。激活刻度会在现有滚动坐标系中计算目标 node 的位置,移动同一个滚动区,并记录由此产生的 Chat 滚动恢复锚点。
即使密集刻度在视觉上重叠,每个 Turn 仍是可访问的按钮。轨道把指针高度映射到最近的已加载 Turn,键盘聚焦和激活则作用于各个按钮。悬停或聚焦显示紧凑的问题与回复预览,活跃刻度更长、更深;Chat 容器宽度不超过 900px 时隐藏轨道,用户偏好减少动态效果时关闭重排和刻度入场动画。
@@ -42,4 +42,4 @@ Chat snapshot 构建层为当前已加载且含可见 transcript node 的每个
## 测试
-构建层测试固定累积投影、预览截断,以及原地 chunk 更新后的预览新鲜度。组件测试固定已发布条目、可访问预览、滚动坐标跳转、DOM 身份以及前插后的百分比重排。长交互 Chromium 场景固定真实分页边界、`加载更早` 后补齐问题、稳定刻度移动、键盘激活、活跃状态更新与窄容器隐藏。多 Turn 的 Web 录制快照包含导航 landmark 和按钮。
+构建层测试固定累积投影、预览截断,以及原地 chunk 更新后的预览新鲜度。组件测试固定已发布条目、可访问预览、滚动坐标跳转、DOM 身份、前插后的百分比重排,以及拒绝任何行几何读取的底部跟随 `ResizeObserver` 更新。长交互 Chromium 场景固定真实分页边界、`加载更早` 后补齐问题、稳定刻度移动、键盘激活、活跃状态更新与窄容器隐藏。多 Turn 的 Web 录制快照包含导航 landmark 和按钮。
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 1e22b05602..d7a5f88cac 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 9fe462316e6ab6d664a43b9fc481e07b01eda093
-README.zh.md: bad8a9143cd0307cdc5edb3e66bb9984cd5ca2be
+README.md: 1b24e6bb6825826a97a22a32f43d0210a2f4c6ad
+README.zh.md: 86596bc73ffd1b41867af6559739ab5426adb7b1
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index 9fe462316e..1b24e6bb68 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -15,6 +15,7 @@ The browser Chat target for Conversation assembly. It registers Chat event defin
- [System prompt row](#system-prompt-row)
- [Turn token usage](#turn-token-usage)
- [Turn Process Folding](#turn-process-folding)
+- [Scroll ownership](#scroll-ownership)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
@@ -42,6 +43,13 @@ Settings → General exposes a persisted `Normal` / `Compact` conversation-displ
-----
+
+## Scroll ownership
+
+Chat restores semantic anchors across history prepend and renderer remounts. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn ([loaded-Turn navigation](../../../.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md)).
+
+-----
+
## Model Experience
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index bad8a9143c..86596bc73f 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -15,6 +15,7 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition
- [系统提示词行](#system-prompt-row)
- [轮次 token 用量](#turn-token-usage)
- [轮次过程折叠](#turn-process-folding)
+- [滚动归属](#scroll-ownership)
- [模型体验](#model-experience)
- [已知限制与暂缓事项](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
@@ -42,6 +43,13 @@ Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真
-----
+
+## 滚动归属
+
+Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载 Turn;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃 Turn([已加载 Turn 导航](../../../.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md))。
+
+-----
+
## 模型体验
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index b2b07d4229..517dccfeb3 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -321,6 +321,11 @@ export function ChatView({
return
}
const el = scrollerOf(local)
+ if (el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1) {
+ const latest = turnNavigationItems.at(-1)?.turn ?? first.turn
+ setActiveTurn(current => current === latest ? current : latest)
+ return
+ }
const readingLine = el.getBoundingClientRect().top + Math.min(96, el.clientHeight * 0.2)
const reading = turnAtLine(local, readingLine)
// No row reaches the line yet: the flow head still owns the mark. Otherwise
@@ -333,9 +338,6 @@ export function ChatView({
next = item.turn
}
}
- if (el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1) {
- next = turnNavigationItems.at(-1)?.turn ?? next
- }
setActiveTurn(current => current === next ? current : next)
}, [turnNavigationItems])
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index d95a3a4099..eccacecc0d 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -2179,6 +2179,58 @@ describe('ChatView', () => {
expect(observe).toHaveBeenCalledTimes(1)
})
+ it('pinned dynamic-height updates select the latest Turn without reading row geometry', () => {
+ let notify: (() => void) | undefined
+ let nextFrame = 0
+ const frames = new Map()
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+ nextFrame += 1
+ frames.set(nextFrame, callback)
+ return nextFrame
+ })
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) })
+ class ResizeObserverStub {
+ constructor(callback: ResizeObserverCallback) {
+ notify = () => { callback([], this as unknown as ResizeObserver) }
+ }
+
+ observe = vi.fn()
+ disconnect = vi.fn()
+ }
+ vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+ const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ .mockReturnValue({ top: 0, bottom: 40 } as DOMRect)
+ const h = makeHarness({
+ nodes: [
+ userInTurn(1, 'first', 1), assistant(2, 'first answer', 1),
+ userInTurn(4, 'second', 2), assistant(5, 'second answer', 2),
+ ],
+ turnEnds: new Map([[1, 3], [2, 6]]),
+ })
+ const view = render( )
+ const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+ const metrics = installScrollMetrics(scroller, 1_000, 300)
+ scroller.scrollTop = 700
+ act(() => {
+ const pending = [...frames.values()]
+ frames.clear()
+ for (const callback of pending) callback(0)
+ })
+ rect.mockClear()
+
+ metrics.setHeight(1_200)
+ act(() => { notify?.() })
+ act(() => {
+ const pending = [...frames.values()]
+ frames.clear()
+ for (const callback of pending) callback(0)
+ })
+
+ expect(scroller.scrollTop).toBe(900)
+ expect(view.getByRole('button', { name: '跳转到第 2 轮' }).getAttribute('aria-current')).toBe('true')
+ expect(rect).not.toHaveBeenCalled()
+ })
+
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render( )
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index d48604ea73..9fcd055b42 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 42c1110e8735dd2191c8b7a2e4dcc1f2b9b938bc
-README.zh.md: 9f3c06cacebb7a596204dbe1c8e00a549ce1fcae
+README.md: 525c2c01ec3f3aa3f6146beb3b3b7a6209998a85
+README.zh.md: b2758c5de1f63d6e211dc462c2386bb9e59e4826
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 42c1110e87..525c2c01ec 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -33,7 +33,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con
### Rendering agent output
-`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks and highlights a growing fence from saved Shiki grammar state; the final render uses the same span tree ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content.
+`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content.
### Localizing copy
@@ -63,7 +63,7 @@ The package is one separation: presentational React atoms with zero Cordis and z
### Streaming markdown
-While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A growing fenced block tokenizes completed text from saved Shiki grammar state plus the unfinished last line; completed lines retain their DOM, and the settled render uses the same span tree. The settled full parse at finalize also resolves references that crossed the freeze boundary ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)).
+While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A final unclosed top-level fence keeps its parsed code node and sends only the last completed line plus the current partial line through the same GFM grammar; a closing fence or ambiguous parse returns to the ordinary tail path. Highlighting likewise resumes from saved Shiki grammar state and publishes only newly completed lines plus the mutable tail. `CodeBlock` seals completed lines into fixed-size React groups, reuses earlier groups, and retains the whole highlighted tree across settlement when code and language are unchanged. The settled full parse still resolves references that crossed the freeze boundary ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)).
### Geometry and overflow
@@ -103,6 +103,7 @@ None; this package neither assembles nor sends a provider request.
These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
- **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it.
+- **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path.
- **Glyph-level icons are redrawn approximations** — the fish logo and the sparkle mark come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
- **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **No `Active` `StateDot` variant** — the supported states are done, warning, ongoing, and error.
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 9f3c06cace..b2758c5de1 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -33,7 +33,7 @@ kind: "package-library"
### 渲染 agent 输出
-`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块,并从保存的 Shiki grammar state 为不断增长的 fence 增量高亮;最终渲染使用相同的 span 树([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。
+`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。
### 本地化文案
@@ -63,7 +63,7 @@ kind: "package-library"
### 流式 markdown
-回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。不断增长的 fenced block 会从已保存的 Shiki grammar state 加上尚未完成的最后一行继续分词;已完成行保留其 DOM,定稿渲染则使用相同的 span 树。定稿时的全量解析还会解析跨过冻结边界的引用([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。
+回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。末尾的顶层未闭合 fence 会保留已解析的 code node,只把最后一个已完成行与当前未完成行交给同一套 GFM grammar;闭合 fence 或有歧义的解析会回到普通尾部路径。高亮同样从保存的 Shiki grammar state 续接,并只发布新完成行与可变尾部。`CodeBlock` 把已完成行封入固定大小的 React 分组、复用更早的分组,并在代码与语言未变化时跨定稿保留整棵高亮树。定稿时的全量解析仍会解析跨过冻结边界的引用([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。
### 几何与溢出
@@ -103,6 +103,7 @@ kind: "package-library"
这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
- **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。
+- **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。
- **字形级图标是重新绘制的近似版本**:鱼形标志与闪光标记来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **`StateDot` 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
index ba9cd43392..04bc7c2062 100644
--- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -5,7 +5,7 @@ import { writeClipboard } from '../clipboard.ts'
import {
StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
} from './highlight.ts'
-import type { HighlightSpan } from './highlight.ts'
+import type { HighlightSpan, StreamingHighlightFrame } from './highlight.ts'
import css from './CodeBlock.module.css'
export interface CodeBlockProps {
@@ -16,9 +16,10 @@ export interface CodeBlockProps {
/**
* The code is still growing (a streaming markdown fence): highlight through
* a per-instance {@link StreamingHighlightSession}, which re-tokenizes only
- * appended text and keeps completed lines' elements (and DOM) untouched.
- * The caller must keep the component instance stable across growth (a
- * stream-stable React key); settled callers omit this and get shiki's HTML.
+ * appended text and keeps completed line groups (and DOM) untouched. The
+ * caller must keep the component instance stable across growth (a
+ * stream-stable React key); an unchanged streamed fence also retains that
+ * tree when it settles. Cold settled callers get shiki's HTML.
*/
streaming?: boolean | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
@@ -41,49 +42,92 @@ const SHIKI_PRE_PROPS = {
tabIndex: 0,
} as const
+/** Completed-line group size; React reconciles groups while the DOM remains line-for-line identical. */
+const STREAMING_LINE_GROUP_SIZE = 32
+
+function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
+ return (
+
+ {index > 0 && '\n'}
+
+ {line.map((span, spanIndex) => {span.text} )}
+
+
+ )
+}
+
export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedLabel }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
// Re-render when a lazy grammar finishes loading, so a fence that showed plain
// text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
- const html = useMemo(
- () => (streaming === true ? undefined : highlightToHtml(trimmed, lang)),
- [streaming, trimmed, lang, loaded],
- )
// Streaming state lives in refs mutated inside the memo (the MarkdownText
// streaming-cache pattern): the session's caches carry across chunks only
// because the owner keys this instance stably while the fence grows.
const sessionRef = useRef(null)
- const lineCacheRef = useRef<{ lines: readonly HighlightSpan[][]; elements: ReactNode[] } | null>(null)
+ const lineCacheRef = useRef<{
+ code: string
+ lang: string | undefined
+ generation: number
+ frame: StreamingHighlightFrame
+ groups: ReactNode[]
+ pending: ReactNode[]
+ nextLine: number
+ body: ReactNode
+ } | null>(null)
+ const settledRef = useRef(false)
const streamedBody = useMemo(() => {
if (streaming !== true) {
+ const previous = lineCacheRef.current
+ if (previous !== null && previous.code === trimmed && previous.lang === lang) {
+ settledRef.current = true
+ return previous.body
+ }
sessionRef.current = null
lineCacheRef.current = null
+ settledRef.current = true
return undefined
}
+ if (settledRef.current) {
+ sessionRef.current = null
+ lineCacheRef.current = null
+ settledRef.current = false
+ }
sessionRef.current ??= new StreamingHighlightSession()
- const lines = sessionRef.current.update(trimmed, lang)
- if (lines === undefined) {
+ const frame = sessionRef.current.updateFrame(trimmed, lang)
+ if (frame === undefined) {
lineCacheRef.current = null
return undefined
}
- // A retained line keeps its span-array identity across chunks, so its
- // cached element is reused and React leaves that line's DOM untouched.
const previous = lineCacheRef.current
- const elements = lines.map((line, index) => previous !== null && previous.lines[index] === line
- ? previous.elements[index]
- : (
-
- {index > 0 && '\n'}
-
- {line.map((span, spanIndex) => {span.text} )}
-
-
- ))
- lineCacheRef.current = { lines, elements }
- return {elements}
+ if (previous?.frame === frame && previous.code === trimmed && previous.lang === lang) {
+ return previous.body
+ }
+ const sameGeneration = previous?.generation === frame.generation
+ const groups = sameGeneration ? [...previous.groups] : []
+ let pending = sameGeneration ? [...previous.pending] : []
+ let nextLine = sameGeneration ? previous.nextLine : 0
+ for (const line of frame.appended) {
+ pending.push(renderLine(line, nextLine))
+ nextLine += 1
+ if (pending.length !== STREAMING_LINE_GROUP_SIZE) continue
+ const start = nextLine - pending.length
+ groups.push({pending} )
+ pending = []
+ }
+ const tail = frame.tail.map((line, index) => renderLine(line, nextLine + index))
+ const tailGroup = {pending}{tail}
+ const body = {groups}{tailGroup}
+ lineCacheRef.current = {
+ code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body,
+ }
+ return body
}, [streaming, trimmed, lang, loaded])
+ const html = useMemo(
+ () => (streaming !== true && streamedBody === undefined ? highlightToHtml(trimmed, lang) : undefined),
+ [streaming, streamedBody, trimmed, lang, loaded],
+ )
const rootRef = useRef(null)
const [copied, setCopied] = useState(false)
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
index 3a26bac424..0b19896979 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
@@ -43,7 +43,11 @@ function renderSettled(
footnoteCounts: new Map(),
}
const blocks = wrapBlockChildren(
- renderBlocks(root.children.map((node, index) => ({ node, key: index })), context),
+ renderBlocks(root.children.map((node, index) => ({
+ node,
+ /* v8 ignore next -- parseFull uses parseGfm, which stamps every top-level node. */
+ key: node.position?.start.offset ?? -(index + 1),
+ })), context),
false,
)
const section = renderFootnoteSection(context)
diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts
index 047f9d1139..d97d8d6c7f 100644
--- a/packages/client/ui-primitives/src/markdown/highlight.ts
+++ b/packages/client/ui-primitives/src/markdown/highlight.ts
@@ -333,10 +333,11 @@ function lineSpans(line: ThemedToken[]): HighlightSpan[] {
* tokenization is line-based and forward-only — a line's tokens depend only on
* its own text and the grammar state entering it — so appended text never
* changes a completed line's tokens. The session caches the spans of every
- * completed line together with the grammar state after them; each
- * {@link update} tokenizes newly completed text from that state, plus the
- * still-growing last line. Per-call cost therefore excludes the completed
- * prefix, and the result equals a from-scratch tokenization of the same code.
+ * completed line together with the grammar state after them;
+ * {@link updateFrame} reports only newly completed lines plus the still-growing
+ * last line, while {@link update} materializes the complete compatibility
+ * result. Per-call tokenization cost therefore excludes the completed prefix,
+ * and the result equals a from-scratch tokenization of the same code.
* Non-append input and a change of resolved grammar reset the cache and
* re-tokenize fully, so any input stays correct.
*/
@@ -352,12 +353,16 @@ export class StreamingHighlightSession {
private lastCode: string | undefined
private lastLang: string | undefined
private lastResult: HighlightSpan[][] | undefined
+ private generation = 0
+ private lastFrame: StreamingHighlightFrame | undefined
private reset(resolved: string | undefined): void {
this.resolved = resolved
this.prefix = ''
this.spans = []
this.state = undefined
+ this.generation += 1
+ this.lastFrame = undefined
}
/** Tokenize `text` with `resolved`, resuming from the cached grammar state when one exists. */
@@ -369,6 +374,43 @@ export class StreamingHighlightSession {
})
}
+ /**
+ * Tokenize one update as a delta for a retained renderer.
+ * @param code - the fence text accumulated so far.
+ * @param lang - the language hint.
+ * @returns Newly completed lines plus the current tail, or `undefined` for the plain arm.
+ */
+ updateFrame(code: string, lang: string | undefined): StreamingHighlightFrame | undefined {
+ if (code === this.lastCode && lang === this.lastLang && this.lastFrame !== undefined) {
+ return this.lastFrame
+ }
+ this.lastCode = code
+ this.lastLang = lang
+ this.lastResult = undefined
+ const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
+ if (resolved === undefined || !ensureGrammar(resolved)) {
+ this.reset(undefined)
+ return undefined
+ }
+ if (resolved !== this.resolved || !code.startsWith(this.prefix)) this.reset(resolved)
+ const firstNewLine = this.spans.length
+ const rest = code.slice(this.prefix.length)
+ const lastNewline = rest.lastIndexOf('\n')
+ if (lastNewline >= 0) {
+ const grownEnd = rest[lastNewline - 1] === '\r' ? lastNewline - 1 : lastNewline
+ const tokens = this.tokenize(resolved, rest.slice(0, grownEnd))
+ for (const line of tokens) this.spans.push(lineSpans(line))
+ this.state = highlighter().getLastGrammarState(tokens)
+ this.prefix = code.slice(0, this.prefix.length + lastNewline + 1)
+ }
+ this.lastFrame = {
+ generation: this.generation,
+ appended: this.spans.slice(firstNewLine),
+ tail: this.tokenize(resolved, rest.slice(lastNewline + 1)).map(lineSpans),
+ }
+ return this.lastFrame
+ }
+
/**
* Tokenize the fence's current text into per-line highlighted runs;
* `undefined` means the caller renders its plain fallback. Idempotent per
@@ -385,39 +427,23 @@ export class StreamingHighlightSession {
if (code === this.lastCode && lang === this.lastLang && this.lastResult !== undefined) {
return this.lastResult
}
- this.lastCode = code
- this.lastLang = lang
- const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
- if (resolved === undefined || !ensureGrammar(resolved)) {
- this.reset(undefined)
- this.lastResult = undefined
- return undefined
- }
- if (resolved !== this.resolved || !code.startsWith(this.prefix)) this.reset(resolved)
- const rest = code.slice(this.prefix.length)
- const lastNewline = rest.lastIndexOf('\n')
- // Everything before the last newline is newly completed lines: tokenize
- // them once from the cached state and retain their spans. What follows is
- // the still-growing line, re-tokenized per call but never retained.
- if (lastNewline >= 0) {
- // Tokenize what shiki's own line splitting would see: splitLines strips
- // the \r of a \r\n terminator (interior pairs are shiki's to split), so
- // a CRLF cut must not leak its \r into the last completed line — a bash
- // continuation's grammar state, for example, differs with it.
- const grownEnd = rest[lastNewline - 1] === '\r' ? lastNewline - 1 : lastNewline
- const tokens = this.tokenize(resolved, rest.slice(0, grownEnd))
- // Per-line push, not one spread call: a reconnect can deliver the whole
- // accumulated fence as one update, and spreading tens of thousands of
- // lines into arguments can exceed the engine's argument limit.
- for (const line of tokens) this.spans.push(lineSpans(line))
- this.state = highlighter().getLastGrammarState(tokens)
- this.prefix = code.slice(0, this.prefix.length + lastNewline + 1)
- }
- this.lastResult = [...this.spans, ...this.tokenize(resolved, rest.slice(lastNewline + 1)).map(lineSpans)]
+ const frame = this.updateFrame(code, lang)
+ if (frame === undefined) return undefined
+ this.lastResult = [...this.spans, ...frame.tail]
return this.lastResult
}
}
+/** One retained-renderer update from {@link StreamingHighlightSession.updateFrame}. */
+export interface StreamingHighlightFrame {
+ /** Changes whenever prior completed lines must be discarded. */
+ readonly generation: number
+ /** Completed lines added since the preceding frame in this generation. */
+ readonly appended: readonly HighlightSpan[][]
+ /** The still-growing final line or lines, replaced by the next frame. */
+ readonly tail: readonly HighlightSpan[][]
+}
+
/**
* Tokenize `code` into per-line highlighted runs when `lang` maps to a
* registered grammar; `undefined` means the caller renders its plain fallback.
diff --git a/packages/client/ui-primitives/src/markdown/incremental.ts b/packages/client/ui-primitives/src/markdown/incremental.ts
index 18638a25b6..dc216e5155 100644
--- a/packages/client/ui-primitives/src/markdown/incremental.ts
+++ b/packages/client/ui-primitives/src/markdown/incremental.ts
@@ -4,19 +4,23 @@
* Re-parsing the whole accumulated document on every streaming chunk is
* quadratic in the final reply length. CommonMark block parsing is line-based
* and appended text can only reshape the parse frontier — the last top-level
- * block (a paragraph becoming a setext heading or a table, a list continuing
- * after a blank line, an unclosed fence swallowing lines) — so earlier blocks
- * are final. This parser therefore freezes all but the trailing
- * {@link UNSTABLE_TAIL_BLOCKS} blocks and re-parses only the source tail
- * behind them: each source region is parsed O(1) times over the stream
- * instead of once per chunk.
+ * block (a paragraph becoming a setext heading or a table, or a list
+ * continuing after a blank line) — so earlier blocks are final. This parser
+ * therefore freezes all but the trailing {@link UNSTABLE_TAIL_BLOCKS} blocks
+ * and re-parses only the source tail behind them. A final unclosed top-level
+ * fence cannot freeze as a block, so its completed content lines use a second
+ * frontier: only the last completed line and current partial line return
+ * through the caller's grammar. Each source region is therefore parsed a
+ * bounded number of times over the stream instead of once per chunk.
*
- * The freeze boundary comes from the parser's own `position` offsets, never
- * from custom source scanning. The cut sits at the *end offset* of the last
- * frozen block (not the next block's start): a following block's start offset
- * excludes up to three spaces of insignificant leading indentation, which is
- * harmless to drop, but cutting at the previous end also keeps the
- * inter-block blank lines in the tail so the sliced source stays verbatim.
+ * The block freeze boundary comes from the parser's own `position` offsets.
+ * The cut sits at the *end offset* of the last frozen block (not the next
+ * block's start): a following block's start offset excludes up to three spaces
+ * of insignificant leading indentation, which is harmless to drop, but
+ * cutting at the previous end also keeps the inter-block blank lines in the
+ * tail so the sliced source stays verbatim. Fence scanning only recognizes a
+ * parser-confirmed code node and closing delimiter; ambiguous input returns to
+ * the normal tail parse.
*
* Known deviation, shared with any prefix-freeze scheme: micromark resolves
* reference-style links and footnotes document-wide at parse time, so a
@@ -24,7 +28,7 @@
* renders literally until the settled full parse self-heals it.
*/
-import type { Root, RootContent } from 'mdast'
+import type { Code, Root, RootContent } from 'mdast'
/**
* Trailing blocks kept unstable. Appended text reshapes at most the last
@@ -68,6 +72,103 @@ function blockKey(node: RootContent, base: number, index: number): number {
return offset === undefined ? -(index + 1) : base + offset
}
+interface OpenFenceState {
+ readonly marker: '`' | '~'
+ readonly markerLength: number
+ readonly syntheticPrefix: string
+ readonly codeIndex: number
+ readonly frozen: readonly PositionedBlock[]
+ readonly tail: readonly PositionedBlock[]
+ readonly pendingStart: number
+ readonly valuePrefix: string
+ readonly end: { readonly line: number; readonly column: number; readonly offset: number }
+ readonly endedWithCarriageReturn: boolean
+}
+
+/** Return the first line terminator at or after `start`, including a CRLF pair. */
+function lineTerminatorEnd(text: string, start: number): number | undefined {
+ for (let index = start; index < text.length; index += 1) {
+ const char = text[index]
+ if (char === '\n') return index + 1
+ if (char === '\r') return text[index + 1] === '\n' ? index + 2 : index + 1
+ }
+ return undefined
+}
+
+/**
+ * Source prefix before the last completed line. Keeping that line beside the
+ * current partial line lets the grammar retain its trailing-newline semantics.
+ */
+function committableLinePrefixLength(text: string): number {
+ let previousEnd = 0
+ let end = 0
+ for (let index = 0; index < text.length; index += 1) {
+ const char = text[index]
+ if (char === '\n') {
+ previousEnd = end
+ end = index + 1
+ continue
+ }
+ if (char !== '\r' || index + 1 >= text.length) continue
+ if (text[index + 1] === '\n') index += 1
+ previousEnd = end
+ end = index + 1
+ }
+ return previousEnd
+}
+
+/** Exact source terminator ending a non-empty committable prefix. */
+function trailingLineTerminator(text: string): '\n' | '\r' | '\r\n' {
+ return text.endsWith('\r\n') ? '\r\n' : text.endsWith('\r') ? '\r' : '\n'
+}
+
+/** Whether `text` contains a CommonMark closing fence on one of its logical lines. */
+function containsClosingFence(text: string, marker: '`' | '~', markerLength: number): boolean {
+ let start = 0
+ while (start <= text.length) {
+ let end = start
+ while (end < text.length && text[end] !== '\n' && text[end] !== '\r') end += 1
+ const line = text.slice(start, end)
+ let indent = 0
+ while (indent < 3 && line[indent] === ' ') indent += 1
+ let run = indent
+ while (line[run] === marker) run += 1
+ if (run - indent >= markerLength && /^[ \t]*$/.test(line.slice(run))) return true
+ if (end === text.length) return false
+ start = text[end] === '\r' && text[end + 1] === '\n' ? end + 2 : end + 1
+ }
+ /* v8 ignore next -- each loop iteration returns at EOF or advances past a line terminator. */
+ return false
+}
+
+/** Advance an mdast point across one append while treating a split CRLF as one line ending. */
+function advancePoint(
+ point: OpenFenceState['end'],
+ appended: string,
+ precededByCarriageReturn: boolean,
+): OpenFenceState['end'] {
+ let line = point.line
+ let column = point.column
+ let afterCarriageReturn = precededByCarriageReturn
+ for (const char of appended) {
+ if (char === '\n') {
+ if (!afterCarriageReturn) line += 1
+ column = 1
+ afterCarriageReturn = false
+ continue
+ }
+ if (char === '\r') {
+ line += 1
+ column = 1
+ afterCarriageReturn = true
+ continue
+ }
+ column += 1
+ afterCarriageReturn = false
+ }
+ return { line, column, offset: point.offset + appended.length }
+}
+
/**
* Append-only incremental parser over a caller-supplied grammar. One instance
* accumulates one streaming document; non-append input resets it.
@@ -78,10 +179,127 @@ export class IncrementalMarkdownParser {
private frozen: PositionedBlock[] = []
private generation = 0
private cached: IncrementalBlocks | null = null
+ private openFence: OpenFenceState | null = null
/** @param parse - Grammar shared with whatever renders the blocks, so boundaries agree. */
constructor(private readonly parse: (text: string) => Root) {}
+ /** Parse one unclosed-fence content slice through the caller's grammar. */
+ private fenceValue(state: Pick, text: string): string | undefined {
+ const root = this.parse(`${state.syntheticPrefix}${text}`)
+ if (root.children.length !== 1) return undefined
+ const node = root.children[0] as RootContent
+ return node.type === 'code' ? node.value : undefined
+ }
+
+ /** Recognize the parsed tail's final unclosed fence and prepare its incremental content frontier. */
+ private openFenceState(
+ text: string,
+ base: number,
+ tail: readonly PositionedBlock[],
+ frozen: readonly PositionedBlock[],
+ ): OpenFenceState | null {
+ const codeIndex = tail.length - 1
+ const block = tail[codeIndex]
+ if (block?.node.type !== 'code') return null
+ const node = block.node
+ const startOffset = node.position?.start.offset
+ const end = node.position?.end
+ if (startOffset === undefined || end?.offset === undefined) return null
+ /* v8 ignore next -- the caller's parse slice ends at text.length, so its final node ends there. */
+ if (base + end.offset !== text.length) return null
+ const source = text.slice(base)
+ const previousLf = source.lastIndexOf('\n', startOffset - 1)
+ const previousCr = source.lastIndexOf('\r', startOffset - 1)
+ const lineStart = Math.max(previousLf, previousCr) + 1
+ const terminatorEnd = lineTerminatorEnd(source, startOffset)
+ /* v8 ignore next -- a parser-confirmed fenced code node requires its opening line terminator. */
+ if (terminatorEnd === undefined) return null
+ if (terminatorEnd === source.length && source.endsWith('\r')) return null
+ const openingLine = source.slice(lineStart, terminatorEnd).replace(/[\r\n]+$/, '')
+ const opening = /^( {0,3})(`{3,}|~{3,})/.exec(openingLine)
+ if (opening === null) return null
+ const indent = opening[1] as string
+ const run = opening[2] as string
+ /* v8 ignore next -- mdast positions a fenced code node at the matched delimiter after indentation. */
+ if (lineStart + indent.length !== startOffset) return null
+ const marker = run[0] as '`' | '~'
+ const contentStart = base + terminatorEnd
+ const content = text.slice(contentStart)
+ if (containsClosingFence(content, marker, run.length)) return null
+ const syntheticPrefix = `${indent}${run}\n`
+ const stableLength = committableLinePrefixLength(content)
+ const stableValue = stableLength === 0
+ ? ''
+ : this.fenceValue({ syntheticPrefix }, content.slice(0, stableLength))
+ if (stableValue === undefined) return null
+ const pendingStart = contentStart + stableLength
+ const stableSource = content.slice(0, stableLength)
+ const valuePrefix = stableLength === 0 ? '' : `${stableValue}${trailingLineTerminator(stableSource)}`
+ const pendingValue = this.fenceValue({ syntheticPrefix }, text.slice(pendingStart))
+ if (pendingValue === undefined || `${valuePrefix}${pendingValue}` !== node.value) return null
+ return {
+ marker,
+ markerLength: run.length,
+ syntheticPrefix,
+ codeIndex,
+ frozen,
+ tail,
+ pendingStart,
+ valuePrefix,
+ end: { line: end.line, column: end.column, offset: end.offset },
+ endedWithCarriageReturn: text.endsWith('\r'),
+ }
+ }
+
+ /** Extend a recognized unclosed fence without parsing its completed content prefix again. */
+ private updateOpenFence(
+ state: OpenFenceState,
+ text: string,
+ previousText: string,
+ ): IncrementalBlocks | undefined {
+ const pending = text.slice(state.pendingStart)
+ if (containsClosingFence(pending, state.marker, state.markerLength)) return undefined
+ const pendingValue = this.fenceValue(state, pending)
+ if (pendingValue === undefined) return undefined
+ const stableLength = committableLinePrefixLength(pending)
+ const stableValue = stableLength === 0
+ ? ''
+ : this.fenceValue(state, pending.slice(0, stableLength))
+ if (stableValue === undefined) return undefined
+ // OpenFenceState is private and is installed only from this exact retained
+ // code entry; updates replace that entry with another positioned Code.
+ const block = state.tail[state.codeIndex] as PositionedBlock
+ const previousNode = block.node as Code & { position: NonNullable }
+ const end = advancePoint(
+ state.end,
+ text.slice(previousText.length),
+ state.endedWithCarriageReturn,
+ )
+ const node: Code = {
+ ...previousNode,
+ value: `${state.valuePrefix}${pendingValue}`,
+ position: { start: previousNode.position.start, end },
+ }
+ const tail = state.tail.map((entry, index) => index === state.codeIndex ? { ...entry, node } : entry)
+ const cached = {
+ frozen: state.frozen,
+ tail,
+ generation: this.generation,
+ }
+ this.openFence = {
+ ...state,
+ tail,
+ pendingStart: state.pendingStart + stableLength,
+ valuePrefix: stableLength === 0
+ ? state.valuePrefix
+ : `${state.valuePrefix}${stableValue}${trailingLineTerminator(pending.slice(0, stableLength))}`,
+ end,
+ endedWithCarriageReturn: text.endsWith('\r'),
+ }
+ return cached
+ }
+
/**
* Fold the current accumulated text and return the frozen/tail split.
* Idempotent for identical input (the previous result is returned as-is),
@@ -101,8 +319,19 @@ export class IncrementalMarkdownParser {
this.prevText = ''
this.tailStart = 0
this.frozen = []
+ this.openFence = null
this.generation += 1
}
+ const previousText = this.prevText
+ if (previousText !== '' && this.openFence !== null) {
+ const incremental = this.updateOpenFence(this.openFence, text, previousText)
+ if (incremental !== undefined) {
+ this.prevText = text
+ this.cached = incremental
+ return incremental
+ }
+ this.openFence = null
+ }
this.prevText = text
const base = this.tailStart
const blocks = this.parse(text.slice(base)).children
@@ -125,6 +354,7 @@ export class IncrementalMarkdownParser {
key: blockKey(node, base, index),
}))
this.cached = { frozen: [...this.frozen], tail, generation: this.generation }
+ this.openFence = this.openFenceState(text, base, tail, this.cached.frozen)
return this.cached
}
}
diff --git a/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx
index f3624b96d4..d0d7a8a205 100644
--- a/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx
@@ -117,6 +117,16 @@ describe('incremental streaming rendering', () => {
live.unmount()
settled.unmount()
})
+
+ it('keeps a highlighted fence mounted across the final full-document parse', () => {
+ const doc = 'before.\n\n```ts\nconst answer = 42\n```\n\nafter.'
+ const live = render( )
+ const line = live.container.querySelector('pre.shiki .line')
+ expect(line).not.toBeNull()
+ live.rerender( )
+ expect(live.container.querySelector('pre.shiki .line')).toBe(line)
+ live.unmount()
+ })
})
describe('incremental parsing is actually in effect', () => {
@@ -145,6 +155,25 @@ describe('incremental parsing is actually in effect', () => {
expect(totalParsed).toBeLessThan(text.length * 5)
})
+ it('parses an open fence through bounded grammar slices as completed lines accumulate', () => {
+ const calls: string[] = []
+ const recording = (text: string): Root => {
+ calls.push(text)
+ return parseGfm(text)
+ }
+ const parser = new IncrementalMarkdownParser(recording)
+ let text = '```ts\n'
+ let result = parser.update(text)
+ for (let index = 0; index < 800; index += 1) {
+ text += `const value${String(index)} = ${String(index)}\n`
+ result = parser.update(text)
+ }
+ const parsed = calls.reduce((sum, call) => sum + call.length, 0)
+ expect(Math.max(...calls.slice(10).map(call => call.length))).toBeLessThan(80)
+ expect(parsed).toBeLessThan(text.length * 4)
+ expect(result.tail.at(-1)?.node).toEqual(parseGfm(text).children[0])
+ })
+
it('shows the documented streaming fingerprint: a definition frozen earlier no longer resolves a new reference, and settling heals it', () => {
const doc = [
'[ref]: https://example.com/target',
@@ -197,6 +226,98 @@ describe('freeze dynamics around frontier-sensitive constructs', () => {
expect(frozenCode?.type === 'code' && frozenCode.value).toContain('looks like a list')
})
+ it('keeps indented CRLF fence nodes equal to a fresh parse, then falls back when the fence closes', () => {
+ const parser = new IncrementalMarkdownParser(parseGfm)
+ const opening = 'p1.\n\np2.\n\np3.\n\n ```ts\r\n'
+ const suffix = ' const a = 1\r\n const b = 2\r\n ```\r\nafter'
+ let text = ''
+ for (const char of `${opening}${suffix}`) {
+ text += char
+ const result = parser.update(text)
+ const actual = [...result.frozen, ...result.tail].at(-1)
+ const expected = parseGfm(text).children.at(-1)
+ expect(actual?.key).toBe(expected?.position?.start.offset)
+ expect(actual?.node.type).toBe(expected?.type)
+ if (actual?.node.type === 'code' && expected?.type === 'code') {
+ expect({ lang: actual.node.lang, meta: actual.node.meta, value: actual.node.value })
+ .toEqual({ lang: expected.lang, meta: expected.meta, value: expected.value })
+ }
+ }
+ })
+
+ it('preserves lone-CR fence lines and ignores indented code as a fence frontier', () => {
+ const parser = new IncrementalMarkdownParser(parseGfm)
+ let text = '```ts\rfirst\r'
+ parser.update(text)
+ text += 'second\rthird'
+ const result = parser.update(text)
+ expect(result.tail.at(-1)?.node).toEqual(parseGfm(text).children.at(-1))
+
+ const indented = ' alpha\n beta\n'
+ const indentedResult = new IncrementalMarkdownParser(parseGfm).update(indented)
+ expect(indentedResult.tail.at(-1)?.node).toEqual(parseGfm(indented).children.at(-1))
+ })
+
+ it('falls back to the full grammar tail when a custom grammar rejects fence slices', () => {
+ type Corruption = 'many' | 'paragraph' | 'mismatch'
+ const custom = (corruption: Corruption): ((text: string) => Root) => (text) => {
+ if (!text.startsWith('```\n')) return parseGfm(text)
+ if (corruption === 'many') return parseGfm('one\n\ntwo')
+ if (corruption === 'paragraph') return parseGfm('one')
+ const root = parseGfm(text)
+ const node = root.children[0]
+ if (node?.type === 'code') node.value += 'mismatch'
+ return root
+ }
+ const cases = [
+ { corruption: 'many' as const, text: '```ts\nfirst' },
+ { corruption: 'paragraph' as const, text: '```ts\nfirst' },
+ { corruption: 'many' as const, text: '```ts\nfirst\nsecond\nthird' },
+ { corruption: 'mismatch' as const, text: '```ts\nfirst' },
+ ]
+ for (const { corruption, text } of cases) {
+ const result = new IncrementalMarkdownParser(custom(corruption)).update(text)
+ expect(result.tail.at(-1)?.node).toEqual(parseGfm(text).children.at(-1))
+ }
+
+ const positionless = new IncrementalMarkdownParser((text) => {
+ const root = parseGfm(text)
+ for (const node of root.children) delete node.position
+ return root
+ }).update('```ts\nfirst')
+ expect(positionless.tail.at(-1)?.node.type).toBe('code')
+ })
+
+ it('abandons an installed fence frontier when later custom-grammar slices fail', () => {
+ let syntheticCall = 0
+ let reject: 'none' | 'first' | 'second' = 'none'
+ const custom = (text: string): Root => {
+ if (!text.startsWith('```\n')) return parseGfm(text)
+ syntheticCall += 1
+ if (reject === 'first' && syntheticCall === 1) return parseGfm('one\n\ntwo')
+ if (reject === 'second' && syntheticCall === 2) return parseGfm('one\n\ntwo')
+ return parseGfm(text)
+ }
+
+ const pendingParser = new IncrementalMarkdownParser(custom)
+ let text = '```ts\nfirst\nsecond'
+ pendingParser.update(text)
+ syntheticCall = 0
+ reject = 'first'
+ text += ' tail'
+ expect(pendingParser.update(text).tail.at(-1)?.node).toEqual(parseGfm(text).children.at(-1))
+
+ reject = 'none'
+ syntheticCall = 0
+ const stableParser = new IncrementalMarkdownParser(custom)
+ text = '```ts\nfirst\nsecond'
+ stableParser.update(text)
+ syntheticCall = 0
+ reject = 'second'
+ text += '\nthird\nfourth'
+ expect(stableParser.update(text).tail.at(-1)?.node).toEqual(parseGfm(text).children.at(-1))
+ })
+
it('a list can keep extending across blank lines until it freezes whole', () => {
const parser = new IncrementalMarkdownParser(parseGfm)
let text = 'intro.\n\nsecond.\n\nthird.\n\n- item a\n- item b\n'
diff --git a/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx b/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx
index 6ed9e43732..3fe0847693 100644
--- a/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx
@@ -67,6 +67,19 @@ describe('StreamingHighlightSession', () => {
expect(second?.[1]).not.toBe(first?.[1])
})
+ it('reports only newly completed lines to a retained renderer', () => {
+ const session = new StreamingHighlightSession()
+ const first = session.updateFrame('const a = 1\nlet', 'ts')
+ const second = session.updateFrame('const a = 1\nlet b = 2\n// tail', 'ts')
+ expect(first?.appended).toHaveLength(1)
+ expect(first?.tail).toHaveLength(1)
+ expect(second?.appended).toHaveLength(1)
+ expect(second?.appended[0]?.map(span => span.text).join('')).toBe('let b = 2')
+ expect(second?.tail[0]?.map(span => span.text).join('')).toBe('// tail')
+ expect(second?.generation).toBe(first?.generation)
+ expect(session.updateFrame('const a = 1\nlet b = 2\n// tail', 'ts')).toBe(second)
+ })
+
it('is idempotent per input: repeated calls return the identical result array', () => {
const session = new StreamingHighlightSession()
const result = session.update('const a = 1', 'ts')
@@ -211,19 +224,46 @@ describe('CodeBlock streaming arm', () => {
expect(view.container.querySelector('pre.shiki')?.textContent).toBe('const a = 1\nlet partial = 2\n// tail')
})
+ it('keeps completed line groups mounted while later groups grow', () => {
+ const code = (count: number) => Array.from({ length: count }, (_, index) => `const v${String(index)} = ${String(index)}`).join('\n')
+ const view = render( )
+ const firstLine = view.container.querySelector('pre.shiki .line')
+ const thirtySecond = view.container.querySelectorAll('pre.shiki .line')[31]
+ view.rerender( )
+ const lines = view.container.querySelectorAll('pre.shiki .line')
+ expect(lines).toHaveLength(80)
+ expect(lines[0]).toBe(firstLine)
+ expect(lines[31]).toBe(thirtySecond)
+ })
+
+ it('reuses an unchanged frame when an unrelated lazy grammar finishes loading', async () => {
+ const view = render( )
+ const line = view.container.querySelector('pre.shiki .line')
+ expect(line).not.toBeNull()
+ const loader = new StreamingHighlightSession()
+ expect(loader.update('puts 1', 'ruby')).toBeUndefined()
+ await vi.waitFor(() => { expect(loader.update('puts 1', 'ruby')).toBeDefined() }, { timeout: 5_000 })
+ expect(view.container.querySelector('pre.shiki .line')).toBe(line)
+ })
+
it('streaming with an unknown language stays on the identical plain arm', () => {
const view = render( )
expect(view.container.querySelector('pre.shiki')).toBeNull()
expect(view.getByText('IDENTIFICATION DIVISION.')).toBeTruthy()
})
- it('the settle swap (streaming to settled) preserves the code content', () => {
+ it('the settle transition preserves the highlighted DOM when the code is unchanged', () => {
const code = 'const answer = 42\n'
const view = render( )
+ const streamedLine = view.container.querySelector('pre.shiki .line')
const streamedText = view.container.querySelector('pre.shiki')?.textContent
view.rerender( )
const settledText = view.container.querySelector('pre.shiki')?.textContent
expect(streamedText).toBe('const answer = 42')
expect(settledText).toBe(streamedText)
+ expect(view.container.querySelector('pre.shiki .line')).toBe(streamedLine)
+ view.rerender( )
+ expect(view.container.querySelector('pre.shiki')?.textContent).toBe(streamedText)
+ expect(view.container.querySelector('pre.shiki .line')).toBe(streamedLine)
})
})
From 2480bdf27ae13b2bf0554d7a357b4a2262c98fa9 Mon Sep 17 00:00:00 2001
From: Ziya
Date: Mon, 31 Aug 2026 17:42:16 +0800
Subject: [PATCH 02/26] feat(web): clarify Workspace Write Chinese label
(#3345)
Co-authored-by: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
---
apps/web/tests/access-confirmation.e2e.ts | 2 +-
apps/web/tests/expected/settings-chrome/dialog.expected.md | 4 ++--
apps/web/tests/settings-chrome.e2e.ts | 4 ++--
packages/client/ui-conversation/src/client/locales.ts | 2 +-
.../client/ui-conversation/tests/input-bar.client.spec.tsx | 6 +++---
packages/client/ui-permission-presets/README.i18n.yaml | 4 ++--
packages/client/ui-permission-presets/README.md | 2 +-
packages/client/ui-permission-presets/README.zh.md | 2 +-
packages/client/ui-permission-presets/src/client/locales.ts | 4 ++--
.../tests/browser-plugin.client.spec.ts | 2 +-
.../tests/permission-presets-row.client.spec.tsx | 6 +++---
11 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts
index e5595c94a8..cd64d98110 100644
--- a/apps/web/tests/access-confirmation.e2e.ts
+++ b/apps/web/tests/access-confirmation.e2e.ts
@@ -50,7 +50,7 @@ describe('web e2e: Full access confirmation', () => {
const access = page.locator('button[aria-label^="访问模式"]').first()
await access.waitFor({ timeout: 10_000 })
- expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:可写入工作区')
+ expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:工作区内修改')
await access.click()
await page.getByRole('menuitem', { name: '完全权限' }).click()
diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md
index 9ff9301454..45ad145095 100644
--- a/apps/web/tests/expected/settings-chrome/dialog.expected.md
+++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md
@@ -18,8 +18,8 @@
- img
- text: 关闭
- text: 权限 选择新会话的默认权限模式
- - button "可写入工作区":
- - text: 可写入工作区
+ - button "工作区内修改":
+ - text: 工作区内修改
- img
- text: 语言
- button "中文":
diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts
index 4b8fd7495f..f86f5c0964 100644
--- a/apps/web/tests/settings-chrome.e2e.ts
+++ b/apps/web/tests/settings-chrome.e2e.ts
@@ -62,7 +62,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
// General is active by default; Permission, Language and Appearance are functional.
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
- await dialog.getByRole('button', { name: '可写入工作区' }).waitFor({ timeout: 10_000 })
+ await dialog.getByRole('button', { name: '工作区内修改' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
@@ -150,7 +150,7 @@ describe('web e2e: settings modal and General preferences', () => {
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
- const selector = dialog.getByRole('button', { name: '可写入工作区' })
+ const selector = dialog.getByRole('button', { name: '工作区内修改' })
await selector.waitFor({ timeout: 10_000 })
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
await selector.click()
diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
index 0e8ba381f4..296f95b33a 100644
--- a/packages/client/ui-conversation/src/client/locales.ts
+++ b/packages/client/ui-conversation/src/client/locales.ts
@@ -56,7 +56,7 @@ export const zh = {
'settings.enter.queue': '排队发送',
'settings.enter.steer': '插话发送',
'access.preset.readOnly': '仅可查看',
- 'access.preset.workspaceWrite': '可写入工作区',
+ 'access.preset.workspaceWrite': '工作区内修改',
'access.preset.fullAccess': '完全权限',
'access.confirm.title': '确认启用完全权限?',
'access.confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
index d616c34a43..94aec70bbf 100644
--- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
@@ -1335,11 +1335,11 @@ describe('command launcher chrome and control seats', () => {
.every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
fireEvent.click(trigger)
const items = view.getAllByRole('menuitem')
- expect(items.map(o => o.textContent)).toEqual(['仅可查看', '可写入工作区', '完全权限'])
+ expect(items.map(o => o.textContent)).toEqual(['仅可查看', '工作区内修改', '完全权限'])
fireEvent.click(items[1]!)
// Optimistic pick + disable until admission resolves (command stub resolves true).
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
- expect(busy.textContent).toBe('可写入工作区')
+ expect(busy.textContent).toBe('工作区内修改')
expect(busy.disabled).toBe(true)
expect(command).toHaveBeenCalledWith('/permission workspace-write')
await act(async () => {})
@@ -1413,7 +1413,7 @@ describe('command launcher chrome and control seats', () => {
fireEvent.click(view.getByRole('checkbox'))
fireEvent.click(view.getByRole('button', { name: '取消' }))
expect(command).not.toHaveBeenCalled()
- expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('可写入工作区')
+ expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('工作区内修改')
openConfirmation()
expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
diff --git a/packages/client/ui-permission-presets/README.i18n.yaml b/packages/client/ui-permission-presets/README.i18n.yaml
index 44382511cf..5ae75b98f1 100644
--- a/packages/client/ui-permission-presets/README.i18n.yaml
+++ b/packages/client/ui-permission-presets/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-permission-presets/README.md
-README.md: 6a82ebe45edb7e6055bf4c69a2e90bf21255696c
-README.zh.md: bd5588b13cc55d856163ff3eadf04f8059b2f7a9
+README.md: a88c241b8d93228b19d40569f67b4311a550a7c7
+README.zh.md: a20d237f2a67f6f0aa085c3e0784a1e0f25c0c1c
diff --git a/packages/client/ui-permission-presets/README.md b/packages/client/ui-permission-presets/README.md
index 6a82ebe45e..a88c241b8d 100644
--- a/packages/client/ui-permission-presets/README.md
+++ b/packages/client/ui-permission-presets/README.md
@@ -29,7 +29,7 @@ Mount this plugin alongside the settings and commands packages; the permission r
### The picker
-A pick submits the `/permission ` command line. The argued path (`/permission ` typed directly) still switches directly; the decoration replaces only the bare invocation. The built-in labels are `Read Only`, `Workspace Write`, and `Full access` in English and `仅可查看`, `可写入工作区`, and `完全权限` in Chinese; `custom` is display state, never a target.
+A pick submits the `/permission ` command line. The argued path (`/permission ` typed directly) still switches directly; the decoration replaces only the bare invocation. The built-in labels are `Read Only`, `Workspace Write`, and `Full access` in English and `仅可查看`, `工作区内修改`, and `完全权限` in Chinese; `custom` is display state, never a target.
### The Settings row
diff --git a/packages/client/ui-permission-presets/README.zh.md b/packages/client/ui-permission-presets/README.zh.md
index bd5588b13c..a20d237f2a 100644
--- a/packages/client/ui-permission-presets/README.zh.md
+++ b/packages/client/ui-permission-presets/README.zh.md
@@ -29,7 +29,7 @@ kind: "package-reference"
### 选择器
-选中即提交 `/permission ` 命令行。带参路径(直接键入 `/permission `)仍直接切换;装饰只替换裸调用。内置标签在英文界面中是 `Read Only`、`Workspace Write` 和 `Full access`,在中文界面中是「仅可查看」「可写入工作区」和「完全权限」;`custom` 只是显示状态,绝非目标。
+选中即提交 `/permission ` 命令行。带参路径(直接键入 `/permission `)仍直接切换;装饰只替换裸调用。内置标签在英文界面中是 `Read Only`、`Workspace Write` 和 `Full access`,在中文界面中是「仅可查看」「工作区内修改」和「完全权限」;`custom` 只是显示状态,绝非目标。
### 设置行
diff --git a/packages/client/ui-permission-presets/src/client/locales.ts b/packages/client/ui-permission-presets/src/client/locales.ts
index 8a1ec2049c..43cd7695b6 100644
--- a/packages/client/ui-permission-presets/src/client/locales.ts
+++ b/packages/client/ui-permission-presets/src/client/locales.ts
@@ -7,7 +7,7 @@ export const zh = {
'loading': '加载中',
'unavailable': '不可用',
'preset.readOnly': '仅可查看',
- 'preset.workspaceWrite': '可写入工作区',
+ 'preset.workspaceWrite': '工作区内修改',
'preset.fullAccess': '完全权限',
'confirm.title': '确认启用完全权限?',
'confirm.description': '启用完全权限后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
@@ -38,7 +38,7 @@ export const en = {
/** Simplified Chinese dictionary for the current-session popup gate. */
export const accessZh = {
'preset.readOnly': '仅可查看',
- 'preset.workspaceWrite': '可写入工作区',
+ 'preset.workspaceWrite': '工作区内修改',
'preset.fullAccess': '完全权限',
'confirm.title': '确认启用完全权限?',
'confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts
index 4ad456b47f..e6f9cd39f8 100644
--- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts
+++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts
@@ -128,7 +128,7 @@ describe('ui-permission browser plugin', () => {
})
b.locale.setLocale('zh')
const localized = await c.ui.options(proj, new AbortController().signal)
- expect(localized.map(option => option.label)).toEqual(['仅可查看', '可写入工作区', '完全权限'])
+ expect(localized.map(option => option.label)).toEqual(['仅可查看', '工作区内修改', '完全权限'])
expect(localized.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
title: '确认启用完全权限?',
description: accessZh['confirm.description'],
diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
index 13693a4e4d..b083a4074a 100644
--- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
+++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
@@ -94,8 +94,8 @@ describe('PermissionRow', () => {
fireEvent.click(screen.getByRole('menuitem', { name: '仅可查看' }))
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(button)
- fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' }))
- await screen.findByRole('button', { name: '可写入工作区' })
+ fireEvent.click(screen.getByRole('menuitem', { name: '工作区内修改' }))
+ await screen.findByRole('button', { name: '工作区内修改' })
expect(mutate).toHaveBeenCalledOnce()
})
@@ -166,7 +166,7 @@ describe('PermissionRow', () => {
describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
const button = await screen.findByRole('button', { name: '仅可查看' })
fireEvent.click(button)
- fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' }))
+ fireEvent.click(screen.getByRole('menuitem', { name: '工作区内修改' }))
expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere')
})
})
From d8e2ac5052b28f065eab4cefcf51482427d17102 Mon Sep 17 00:00:00 2001
From: 07akioni <07akioni2@gmail.com>
Date: Mon, 31 Aug 2026 18:11:32 +0800
Subject: [PATCH 03/26] fix(web): retain completed streaming fence lines
---
.../tests/streaming-fence-highlight.e2e.ts | 69 +++++++++++++++----
.../ui-primitives/src/markdown/CodeBlock.tsx | 2 +-
.../streaming-code-block.client.spec.tsx | 30 ++++++++
3 files changed, 85 insertions(+), 16 deletions(-)
diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts
index 4e74eb8764..6d65cdca0d 100644
--- a/apps/web/tests/streaming-fence-highlight.e2e.ts
+++ b/apps/web/tests/streaming-fence-highlight.e2e.ts
@@ -24,28 +24,48 @@ const MODE = webSnapshotMode()
const PROVIDER = 'streaming-fence-highlight-test'
const MODEL = 'streaming-fence'
const PROMPT = 'Stream one TypeScript fence for the highlighting snapshot.'
-const OPEN_REPLY = '```ts\nconst first: number = 1\nconst second = "two"\nlet tail'
+const FIRST_REPLY = '```ts\nconst first: number = 1\n'
+const OPEN_REPLY = `${FIRST_REPLY}const second = "two"\nlet tail`
const REPLY = `${OPEN_REPLY}\n\`\`\``
-/** Deterministic model response held after the visible fence body arrives. */
+/** Deterministic model response held after each visible fence-growth frame. */
class StreamingFenceAdapter extends LlmAdapter {
- private resolvePaused!: () => void
- private resolveContinuation!: () => void
- private continued = false
- readonly paused = new Promise((resolve) => { this.resolvePaused = resolve })
- private readonly continuation = new Promise((resolve) => { this.resolveContinuation = resolve })
+ private resolveFirstPaused!: () => void
+ private resolveFirstContinuation!: () => void
+ private resolveSecondPaused!: () => void
+ private resolveSecondContinuation!: () => void
+ private firstContinued = false
+ private secondContinued = false
+ readonly firstPaused = new Promise((resolve) => { this.resolveFirstPaused = resolve })
+ readonly secondPaused = new Promise((resolve) => { this.resolveSecondPaused = resolve })
+ private readonly firstContinuation = new Promise((resolve) => { this.resolveFirstContinuation = resolve })
+ private readonly secondContinuation = new Promise((resolve) => { this.resolveSecondContinuation = resolve })
+
+ grow(): void {
+ if (this.firstContinued) return
+ this.firstContinued = true
+ this.resolveFirstContinuation()
+ }
+
+ finish(): void {
+ if (this.secondContinued) return
+ this.secondContinued = true
+ this.resolveSecondContinuation()
+ }
continue(): void {
- if (this.continued) return
- this.continued = true
- this.resolveContinuation()
+ this.grow()
+ this.finish()
}
override async *stream(options: GenerateOptions): AsyncIterable {
yield { type: 'block-start', index: 0, blockType: 'text' }
- yield { type: 'text-delta', index: 0, text: OPEN_REPLY }
- this.resolvePaused()
- await this.continuation
+ yield { type: 'text-delta', index: 0, text: FIRST_REPLY }
+ this.resolveFirstPaused()
+ await this.firstContinuation
+ yield { type: 'text-delta', index: 0, text: OPEN_REPLY.slice(FIRST_REPLY.length) }
+ this.resolveSecondPaused()
+ await this.secondContinuation
if (options.signal?.aborted === true) throw options.signal.reason
yield { type: 'text-delta', index: 0, text: '\n```' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }
@@ -115,12 +135,26 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting',
const settled = scaffold.whenTurnSettled(30_000)
await writeComposerDraft(page, input, PROMPT)
await input.press('Enter')
- await adapter.paused
+ await adapter.firstPaused
const streaming = page.locator('[data-streaming="true"]')
await streaming.waitFor({ timeout: 10_000 })
const block = streaming.locator('.md-code-block').filter({ hasText: 'const first' })
await block.locator('pre.shiki span[style]').first().waitFor({ timeout: 10_000 })
+ await block.evaluate((element) => {
+ element.setAttribute('data-stream-block-retained', 'true')
+ element.querySelector('pre.shiki')?.setAttribute('data-stream-pre-retained', 'true')
+ element.querySelector('pre.shiki .line')?.setAttribute('data-stream-line-retained', 'true')
+ })
+
+ adapter.grow()
+ await adapter.secondPaused
+ await expect.poll(() => block.locator('pre.shiki .line').count()).toBe(3)
+ expect(await block.evaluate(element => ({
+ block: element.getAttribute('data-stream-block-retained'),
+ pre: element.querySelector('pre.shiki')?.getAttribute('data-stream-pre-retained'),
+ line: element.querySelector('pre.shiki .line')?.getAttribute('data-stream-line-retained'),
+ }))).toEqual({ block: 'true', pre: 'true', line: 'true' })
const midTree = await fenceTree(block)
expect(midTree.language).toBe('ts')
expect(midTree.lines).toHaveLength(3)
@@ -133,12 +167,17 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting',
MODE,
)
- adapter.continue()
+ adapter.finish()
await settled
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
const settledBlock = page.locator('.md-code-block').filter({ hasText: 'const first' })
await settledBlock.locator('pre.shiki').waitFor({ timeout: 10_000 })
expect(await fenceTree(settledBlock)).toEqual(midTree)
+ expect(await settledBlock.evaluate(element => ({
+ block: element.getAttribute('data-stream-block-retained'),
+ pre: element.querySelector('pre.shiki')?.getAttribute('data-stream-pre-retained'),
+ line: element.querySelector('pre.shiki .line')?.getAttribute('data-stream-line-retained'),
+ }))).toEqual({ block: 'true', pre: 'true', line: 'true' })
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md'])
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
index 04bc7c2062..2a42092020 100644
--- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -117,7 +117,7 @@ export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedL
pending = []
}
const tail = frame.tail.map((line, index) => renderLine(line, nextLine + index))
- const tailGroup = {pending}{tail}
+ const tailGroup = {[...pending, ...tail]}
const body = {groups}{tailGroup}
lineCacheRef.current = {
code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body,
diff --git a/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx b/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx
index 3fe0847693..25cd22dcb9 100644
--- a/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx
@@ -80,6 +80,26 @@ describe('StreamingHighlightSession', () => {
expect(session.updateFrame('const a = 1\nlet b = 2\n// tail', 'ts')).toBe(second)
})
+ it('emits one completed line per frame across an 800-line stream', () => {
+ const session = new StreamingHighlightSession()
+ let code = ''
+ let generation: number | undefined
+ let appended = 0
+ for (let index = 0; index < 800; index += 1) {
+ const line = `const value${String(index)} = ${String(index)}`
+ code += `${line}\n`
+ const frame = session.updateFrame(code, 'ts')
+ expect(frame?.appended).toHaveLength(1)
+ expect(frame?.appended[0]?.map(span => span.text).join('')).toBe(line)
+ generation ??= frame?.generation
+ expect(frame?.generation).toBe(generation)
+ appended += frame?.appended.length ?? 0
+ }
+ // Frame cardinality is stable across CI hosts; wall-clock thresholds are
+ // diagnostics owned by the manual Web performance inventory.
+ expect(appended).toBe(800)
+ })
+
it('is idempotent per input: repeated calls return the identical result array', () => {
const session = new StreamingHighlightSession()
const result = session.update('const a = 1', 'ts')
@@ -224,6 +244,16 @@ describe('CodeBlock streaming arm', () => {
expect(view.container.querySelector('pre.shiki')?.textContent).toBe('const a = 1\nlet partial = 2\n// tail')
})
+ it('keeps a tail line mounted when the next frame completes it', () => {
+ const view = render( )
+ const firstLine = view.container.querySelector('pre.shiki .line')
+ expect(firstLine).not.toBeNull()
+ view.rerender(
+ ,
+ )
+ expect(view.container.querySelector('pre.shiki .line')).toBe(firstLine)
+ })
+
it('keeps completed line groups mounted while later groups grow', () => {
const code = (count: number) => Array.from({ length: count }, (_, index) => `const v${String(index)} = ${String(index)}`).join('\n')
const view = render( )
From 7e2eacb1fe6496b1b4cd5a3d68d29e07dba89fc8 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 12:50:56 +0800
Subject: [PATCH 04/26] feat(session-turn-outline): whole-log turn outline
projection
New turnOutline projection unit serving every started turn's number,
turn/start seq, and bounded first-prompt preview through the
session-projection seam, mounted in the web-app bundle for the chat
turn rail. Entries stay strictly increasing; previews mirror the rail's
loaded-turn preview budget.
---
docs/config-catalog.i18n.yaml | 4 +-
docs/config-catalog.md | 1 +
docs/config-catalog.zh.md | 1 +
docs/module-graph.i18n.yaml | 4 +-
docs/module-graph.md | 6 +
docs/module-graph.zh.md | 6 +
packages/bundle/web-app/cordis.patch.yml | 5 +
packages/bundle/web-app/package.json | 1 +
packages/session/README.i18n.yaml | 4 +-
packages/session/README.md | 1 +
packages/session/README.zh.md | 1 +
.../session-turn-outline/README.i18n.yaml | 6 +
.../session/session-turn-outline/README.md | 124 +++++++++++++
.../session/session-turn-outline/README.zh.md | 124 +++++++++++++
.../session/session-turn-outline/package.json | 62 +++++++
.../session-turn-outline/src/client.ts | 10 ++
.../session/session-turn-outline/src/index.ts | 29 +++
.../session-turn-outline/src/invariant.ts | 35 ++++
.../session-turn-outline/src/projection.ts | 91 ++++++++++
.../session/session-turn-outline/src/types.ts | 37 ++++
.../tests/loader-composition.spec.ts | 88 ++++++++++
.../tests/projection.spec.ts | 165 ++++++++++++++++++
.../session-turn-outline/tsconfig.json | 30 ++++
pnpm-lock.yaml | 31 ++++
.../verify-package-readme-model-experience.ts | 1 +
tsconfig.base.json | 4 +
tsconfig.host.json | 1 +
27 files changed, 866 insertions(+), 6 deletions(-)
create mode 100644 packages/session/session-turn-outline/README.i18n.yaml
create mode 100644 packages/session/session-turn-outline/README.md
create mode 100644 packages/session/session-turn-outline/README.zh.md
create mode 100644 packages/session/session-turn-outline/package.json
create mode 100644 packages/session/session-turn-outline/src/client.ts
create mode 100644 packages/session/session-turn-outline/src/index.ts
create mode 100644 packages/session/session-turn-outline/src/invariant.ts
create mode 100644 packages/session/session-turn-outline/src/projection.ts
create mode 100644 packages/session/session-turn-outline/src/types.ts
create mode 100644 packages/session/session-turn-outline/tests/loader-composition.spec.ts
create mode 100644 packages/session/session-turn-outline/tests/projection.spec.ts
create mode 100644 packages/session/session-turn-outline/tsconfig.json
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 49cb7bb2c7..8a605e9add 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: ec077edd10962f324db242698b1d652563c3ac2f
-config-catalog.zh.md: d349575cb2884bd2e80097c8345db8d0227107c7
+config-catalog.md: 5d19ab42f0423cd92fd6227bf1c169db1269d9d4
+config-catalog.zh.md: 77fdabb9da9b78aba6c9704fd0ec5c095ea14679
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index ec077edd10..5d19ab42f0 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -3353,6 +3353,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts))
- `@deepseek-ai/dsh-session-projection` ([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts))
- `@deepseek-ai/dsh-session-stats` — requires `sessionProjections` ([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts))
+- `@deepseek-ai/dsh-session-turn-outline` — requires `sessionProjections` ([`packages/session/session-turn-outline/src/index.ts`](../packages/session/session-turn-outline/src/index.ts))
- `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts))
- `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index d349575cb2..77fdabb9da 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -3355,6 +3355,7 @@ export interface Config {
- `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts))
- `@deepseek-ai/dsh-session-projection`([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts))
- `@deepseek-ai/dsh-session-stats` — 需要 `sessionProjections`([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts))
+- `@deepseek-ai/dsh-session-turn-outline` — 需要 `sessionProjections`([`packages/session/session-turn-outline/src/index.ts`](../packages/session/session-turn-outline/src/index.ts))
- `@deepseek-ai/dsh-skill-badge` — 需要 `skills`([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts))
- `@deepseek-ai/dsh-storage`([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
- `@deepseek-ai/dsh-subagent`([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index cbec50a70c..6486a6d319 100644
--- a/docs/module-graph.i18n.yaml
+++ b/docs/module-graph.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 2229efcd7e76b3b224eb307ee7de9ecea0ad85d7
-module-graph.zh.md: 3edca4ea261f68593973149b21e72e4a25d7a504
+module-graph.md: 1fa0aec5cf88f8deaa4e3bfd6687c7dc8c06f669
+module-graph.zh.md: a9da000b8966fa8daaca13c95af097e4b342d633
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 2229efcd7e..1fa0aec5cf 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -296,6 +296,7 @@ flowchart TD
pkg_session_title_all_prompts_llm["session-title-all-prompts-llm"]
pkg_session_title_first_prompt_llm["session-title-first-prompt-llm"]
pkg_session_title_llm["session-title-llm"]
+ pkg_session_turn_outline["session-turn-outline"]
end
subgraph group_settings["packages/settings"]
pkg_settings["settings"]
@@ -530,6 +531,10 @@ flowchart TD
pkg_session_stats --> pkg_llm
pkg_session_stats --> pkg_session
pkg_session_stats --> pkg_session_projection
+ pkg_session_turn_outline --> pkg_invariants
+ pkg_session_turn_outline --> pkg_llm
+ pkg_session_turn_outline --> pkg_session
+ pkg_session_turn_outline --> pkg_session_projection
pkg_settings_file --> pkg_atomic_write
pkg_settings_file --> pkg_home_paths
pkg_settings_file --> pkg_invariants
@@ -1431,6 +1436,7 @@ flowchart TD
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
| [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
+| [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
| [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
| [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 3edca4ea26..a9da000b89 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -298,6 +298,7 @@ flowchart TD
pkg_session_title_all_prompts_llm["session-title-all-prompts-llm"]
pkg_session_title_first_prompt_llm["session-title-first-prompt-llm"]
pkg_session_title_llm["session-title-llm"]
+ pkg_session_turn_outline["session-turn-outline"]
end
subgraph group_settings["packages/settings"]
pkg_settings["settings"]
@@ -532,6 +533,10 @@ flowchart TD
pkg_session_stats --> pkg_llm
pkg_session_stats --> pkg_session
pkg_session_stats --> pkg_session_projection
+ pkg_session_turn_outline --> pkg_invariants
+ pkg_session_turn_outline --> pkg_llm
+ pkg_session_turn_outline --> pkg_session
+ pkg_session_turn_outline --> pkg_session_projection
pkg_settings_file --> pkg_atomic_write
pkg_settings_file --> pkg_home_paths
pkg_settings_file --> pkg_invariants
@@ -1433,6 +1438,7 @@ flowchart TD
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
| [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
+| [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
| [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
| [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) |
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
index 15a5df12cc..79c62a6e20 100644
--- a/packages/bundle/web-app/cordis.patch.yml
+++ b/packages/bundle/web-app/cordis.patch.yml
@@ -72,6 +72,11 @@
- id: session-stats
name: '@deepseek-ai/dsh-session-stats'
+ # Whole-log turn outline for the chat turn rail (the turnOutline
+ # projection key): every turn stays navigable before its events page in.
+ - id: session-turn-outline
+ name: '@deepseek-ai/dsh-session-turn-outline'
+
# Resolve bind host, SSH launch, and display once at boot, then mount the
# matching dual-face directory picker. Mount -native or -browse directly in
# an overlay to pin the interaction.
diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json
index 2643f13062..0ba57ce2b9 100644
--- a/packages/bundle/web-app/package.json
+++ b/packages/bundle/web-app/package.json
@@ -107,6 +107,7 @@
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-log-export": "workspace:^",
"@deepseek-ai/dsh-session-stats": "workspace:^",
+ "@deepseek-ai/dsh-session-turn-outline": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-settings-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
diff --git a/packages/session/README.i18n.yaml b/packages/session/README.i18n.yaml
index 6a8abb968a..ec8aaf2ef0 100644
--- a/packages/session/README.i18n.yaml
+++ b/packages/session/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/README.md
-README.md: 63cc118decffaec1073c75d9d8c5967f866016fa
-README.zh.md: f24883518d779dd4cd069e828d4d6ba01d8caa07
+README.md: b47bd7e6384919af4add2746417b232f6873aaf0
+README.zh.md: fa5bc08fc04d32de15f9fe357e7b18b23bb084d3
diff --git a/packages/session/README.md b/packages/session/README.md
index 63cc118dec..b47bd7e638 100644
--- a/packages/session/README.md
+++ b/packages/session/README.md
@@ -40,6 +40,7 @@ The group splits into four families: durable storage (persistence seam, backends
| [`session-projection/`](session-projection/README.md) | Defines and drives projection units that fold committed events into whole current values | `ctx.sessionProjections` |
| [`session-projection-cache/`](session-projection-cache/README.md) | Persists projection checkpoints so cold reads skip full log loads | `ctx.sessionProjectionCache` |
| [`session-stats/`](session-stats/README.md) | Serves whole-log conversation counts and wall times through the `sessionStats` unit | registers on `ctx.sessionProjections` |
+| [`session-turn-outline/`](session-turn-outline/README.md) | Serves the whole-log turn outline (turn, `turn/start` seq, prompt preview) through the `turnOutline` unit | registers on `ctx.sessionProjections` |
### Titles
diff --git a/packages/session/README.zh.md b/packages/session/README.zh.md
index f24883518d..fa5bc08fc0 100644
--- a/packages/session/README.zh.md
+++ b/packages/session/README.zh.md
@@ -40,6 +40,7 @@ session 组让 agent(智能体)的对话在实时 loop 之外持久可复用
| [`session-projection/`](session-projection/README.zh.md) | 定义并驱动把已提交事件折叠为完整当前值的投影单元 | `ctx.sessionProjections` |
| [`session-projection-cache/`](session-projection-cache/README.zh.md) | 持久化投影检查点,使冷读跳过全量日志加载 | `ctx.sessionProjectionCache` |
| [`session-stats/`](session-stats/README.zh.md) | 通过 `sessionStats` 单元提供全日志会话计数与墙钟时间 | 注册到 `ctx.sessionProjections` |
+| [`session-turn-outline/`](session-turn-outline/README.zh.md) | 通过 `turnOutline` 单元提供全日志轮次大纲(轮次号、`turn/start` seq、提示词预览) | 注册到 `ctx.sessionProjections` |
### 标题
diff --git a/packages/session/session-turn-outline/README.i18n.yaml b/packages/session/session-turn-outline/README.i18n.yaml
new file mode 100644
index 0000000000..c27b36d44a
--- /dev/null
+++ b/packages/session/session-turn-outline/README.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/session/session-turn-outline/README.md
+README.md: f56445df70fcfd637657c4f7640dd5c415af7063
+README.zh.md: f10a0a61d9df6534d8e2f9566814c6112e2e678e
diff --git a/packages/session/session-turn-outline/README.md b/packages/session/session-turn-outline/README.md
new file mode 100644
index 0000000000..f56445df70
--- /dev/null
+++ b/packages/session/session-turn-outline/README.md
@@ -0,0 +1,124 @@
+---
+description: "Whole-log turn outline for clients and maintainers composing or debugging the turnOutline projection unit behind full-session turn navigation."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-session-turn-outline
+
+English | [中文](README.zh.md)
+
+## Summary
+
+`dsh-session-turn-outline` serves the whole-log turn outline — every started turn with its `turn/start` seq and a bounded first-prompt preview — as the `turnOutline` projection unit. A client that pages history in windows reads the outline to offer every turn of the session (loaded or not) and to target its backwards paging at the exact seq that brings a turn's events in. Choose it in compositions that already mount the projection registry, such as the web app bundle whose chat turn rail is the reference consumer; assemblies without the registry are unaffected and their consumers fall back to loaded-window navigation. Setup and entry semantics come first; the fold internals live in a collapsible developer section below.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+
+## Use this package
+
+Mount the plugin beside the session store and the projection registry when clients should navigate every turn of a session without holding its complete event log. The unit registers only when the registry is present.
+
+### Composition
+
+```yaml
+- name: '@deepseek-ai/dsh-session'
+- name: '@deepseek-ai/dsh-session-projection'
+- name: '@deepseek-ai/dsh-session-turn-outline'
+```
+
+### What an entry means
+
+| Field | Meaning |
+|---|---|
+| `turn` | Host-assigned turn number from the `turn/start` payload |
+| `seq` | The turn's `turn/start` event seq — paging a window back through this seq loads the whole turn |
+| `prompt` | Preview of the turn's first human prompt (space-joined text blocks, collapsed whitespace, 160-character cap); `''` until an eligible prompt lands |
+
+Entries are strictly increasing by `turn`, and the wire value is the complete outline (whole-value rule): consumers replace, never merge. Only `user/message` events with the human `user` source fill previews, so injected context and tool results never leak into navigation; a turn whose prompt is images-only keeps `''` and consumers label it by number. The preview budget matches the chat rail's loaded-turn preview, so a turn shows the same words before and after its events load.
+
+### Failures and recovery
+
+The unit is inert without the projection registry: `inject` keeps the fiber pending and nothing registers, so other assemblies lack the `turnOutline` key. Unmounting the plugin removes the key, because registrations are effects on the mounting fiber. Persisted-cache rows are schema-validated on restore — including the strictly-increasing turn order — so a corrupt row is discarded instead of seeding a broken fold.
+
+-----
+
+
+## Understand the implementation
+
+
+Implementation internals — click to expand
+
+This section explains the fold behind the outline; the observable behavior is fully covered in [Use this package](#use-this-package).
+
+### Design concept
+
+The unit is a pure fold over committed session events. `turn/start` — not the prompt `user/message` — anchors each entry because its seq is the load-through target for a jump: the agent loop logs `turn/start` before the turn's prompt and steps, so a window paged back through that seq contains the whole turn. The preview then fills from the first human `user/message`, and only while the newest entry is still empty — later human messages in the same turn (steering) keep the first preview.
+
+### Source map
+
+| File | Role |
+|---|---|
+| [`src/index.ts`](src/index.ts) | Plugin entry: `inject`, unit registration on the mounting fiber |
+| [`src/projection.ts`](src/projection.ts) | The fold: entry append, preview fill, wire view |
+| [`src/types.ts`](src/types.ts) | One home of the `turnOutline` projection-key declaration and entry types |
+
+### Fold rules
+
+- Uninteresting events return the same state reference; the registry's `Object.is` gate keeps the change feed quiet — the outline moves at most twice per turn.
+- A `turn/start` that does not advance the turn number is skipped, keeping the outline sorted; a retried boundary's prompt then lands on the standing entry.
+- State and wire view are the same value, so the persisted-cache state schema is the wire schema.
+
+
+
+-----
+
+
+## Further Exploration
+
+Read these pages when the unit's contract is not enough. They move from the registry that drives units to adjacent session packages.
+
+- [Session projection subsystem](../../../docs/subsystems/session-projection.md) — the registry that drives units and serves snapshot and change-feed values.
+- [Session projection registry package](../session-projection/README.md) — the registry contract units register against.
+- [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages.
+
+-----
+
+
+## Model Experience
+
+None, as the turnOutline unit folds already-logged turn boundaries into a client-facing read model and registers nothing model-facing.
+
+#### KV Cache effect
+
+None; the package never assembles or sends provider requests.
+
+## Known Limitations and Deferred Work
+
+
+
+
+These limits define what the outline describes and when the unit is absent. They are current package constraints.
+
+- **The wire value grows with the session** — every change pushes the complete outline (whole-value rule), roughly 200 bytes per turn; splitting previews into an on-demand read is deferred until sessions with many thousands of turns need it.
+- **Previews carry the prompt only** — assistant-response previews stay window-scoped in the consumer; the outline never re-reads message bodies.
+- **A turn without an eligible text prompt keeps `''`** — images-only and command-only turns are navigable but labeled by number.
+- **Mounted only where the projection registry is composed** — other assemblies serve no `turnOutline` key, and their consumers fall back to loaded-window navigation.
+
+
+### Dev Note
+
+
+Working context for maintainers — click to expand
+
+None.
+
+
diff --git a/packages/session/session-turn-outline/README.zh.md b/packages/session/session-turn-outline/README.zh.md
new file mode 100644
index 0000000000..f10a0a61d9
--- /dev/null
+++ b/packages/session/session-turn-outline/README.zh.md
@@ -0,0 +1,124 @@
+---
+description: "面向组合或调试 turnOutline 投影单元的客户端与维护者的全量轮次大纲说明,支撑整会话轮次导航。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-session-turn-outline
+
+[English](README.md) | 中文
+
+## 概述
+
+`dsh-session-turn-outline` 以 `turnOutline` 投影单元提供全日志的轮次大纲——每个已开始的轮次连同其 `turn/start` seq 与有界的首条提示词预览。按窗口分页历史的客户端读取大纲即可提供会话的每一轮(无论是否已加载),并把向后分页精确定位到能载入某轮事件的 seq。在已挂载投影注册表的组合中选择它,例如以聊天轮次导航栏为参考消费者的 Web 应用包;没有注册表的装配不受影响,其消费者回退到仅按已加载窗口导航。用法与条目语义在前;折叠内部细节放在下方可折叠的开发者章节中。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+
+## 使用本包
+
+当客户端需要在不持有完整事件日志的情况下导航会话的每一轮时,在会话存储与投影注册表旁挂载此插件。只有存在注册表时单元才会注册。
+
+### 组合
+
+```yaml
+- name: '@deepseek-ai/dsh-session'
+- name: '@deepseek-ai/dsh-session-projection'
+- name: '@deepseek-ai/dsh-session-turn-outline'
+```
+
+### 各字段含义
+
+| 字段 | 含义 |
+|---|---|
+| `turn` | `turn/start` 载荷里的宿主分配轮次号 |
+| `seq` | 该轮 `turn/start` 事件的 seq——窗口向后分页越过此 seq 即载入整轮 |
+| `prompt` | 该轮首条人类提示词的预览(文本块以空格连接、空白折叠、160 字符封顶);合格提示词落日志前为 `''` |
+
+条目按 `turn` 严格递增,wire 值是完整大纲(整值规则):消费者整体替换,从不合并。只有带人类 `user` 来源的 `user/message` 事件才会填充预览,注入的上下文与工具结果绝不进入导航;纯图片提示词的轮次保持 `''`,消费者按轮次号标注。预览预算与聊天导航栏已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。
+
+### 失败与恢复
+
+没有投影注册表时单元是惰性的:`inject` 使 fiber 保持挂起,不注册任何内容,因此其他装配缺少 `turnOutline` 键。卸载插件会移除该键,因为注册是挂载 fiber 上的 effect。持久缓存行在恢复时经受 schema 校验——包括轮次严格递增的顺序——损坏的行被丢弃而不会喂坏折叠。
+
+-----
+
+
+## 理解实现
+
+
+实现细节——点击展开
+
+本节解释大纲背后的折叠;可观察行为已在[使用本包](#use-this-package)中完整说明。
+
+### 设计理念
+
+该单元是对已提交会话事件的纯折叠。锚定每个条目的是 `turn/start` 而非提示词 `user/message`,因为它的 seq 就是跳转的载入目标:agent loop 先记 `turn/start` 再记该轮的提示词与步骤,窗口向后分页越过该 seq 即包含整轮。预览随后由首条人类 `user/message` 填充,且仅当最新条目仍为空时——同一轮内后续的人类消息(steering)保留首个预览。
+
+### 源码地图
+
+| 文件 | 职责 |
+|---|---|
+| [`src/index.ts`](src/index.ts) | 插件入口:`inject`、在挂载 fiber 上注册单元 |
+| [`src/projection.ts`](src/projection.ts) | 折叠:条目追加、预览填充、wire 视图 |
+| [`src/types.ts`](src/types.ts) | `turnOutline` 投影键声明与条目类型的唯一归属 |
+
+### 折叠规则
+
+- 不相关事件返回同一状态引用;注册表的 `Object.is` 门禁保持变更流安静——大纲每轮至多变动两次。
+- 未推进轮次号的 `turn/start` 被跳过,保持大纲有序;重试边界的提示词随后落在既有条目上。
+- 状态与 wire 视图是同一个值,因此持久缓存的状态 schema 就是 wire schema。
+
+
+
+-----
+
+
+## 进一步探索
+
+当单元约定不够用时阅读以下页面。它们从驱动单元的注册表逐步进入相邻的会话包。
+
+- [会话投影子系统](../../../docs/subsystems/session-projection.zh.md)——驱动单元并提供快照与变更流值的注册表。
+- [会话投影注册表包](../session-projection/README.zh.md)——单元注册所依据的注册表约定。
+- [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。
+
+-----
+
+
+## 模型体验
+
+无,因为 turnOutline 单元把已写入日志的轮次边界折叠成面向客户端的读模型,不注册任何面向模型的内容。
+
+#### KV Cache 影响
+
+无;本包从不组装或发送提供方请求。
+
+## 已知限制与延期工作
+
+
+
+
+这些限制说明大纲描述什么、单元何时缺失。它们是当前包约束。
+
+- **wire 值随会话增长**——每次变更推送完整大纲(整值规则),约每轮 200 字节;把预览拆成按需读取推迟到数千轮量级的会话真正需要时。
+- **预览只含提示词**——助手回复预览仍由消费者按窗口提供;大纲从不回读消息正文。
+- **没有合格文本提示词的轮次保持 `''`**——纯图片、纯命令的轮次可导航但按轮次号标注。
+- **仅在组合了投影注册表时挂载**——其他装配不提供 `turnOutline` 键,其消费者回退到仅按已加载窗口导航。
+
+
+### 开发备注
+
+
+维护者的工作上下文——点击展开
+
+无。
+
+
diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json
new file mode 100644
index 0000000000..7c12ba5162
--- /dev/null
+++ b/packages/session/session-turn-outline/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "@deepseek-ai/dsh-session-turn-outline",
+ "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness",
+ "version": "0.1.2-alpha.1",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/session/session-turn-outline"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
+ "./types": {
+ "types": "./lib/types/types.d.ts",
+ "default": "./lib/types/types.js"
+ },
+ "./client": {
+ "types": "./lib/types/client.d.ts",
+ "default": "./lib/types/client.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/invariant.js",
+ "lib/types/**/*.js",
+ "lib/types/**/*.d.ts"
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
+ },
+ "dependencies": {
+ "zod": "^4.4.3"
+ },
+ "devDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/cordis-plugin-include": "workspace:^",
+ "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
+ }
+}
diff --git a/packages/session/session-turn-outline/src/client.ts b/packages/session/session-turn-outline/src/client.ts
new file mode 100644
index 0000000000..9d15affea0
--- /dev/null
+++ b/packages/session/session-turn-outline/src/client.ts
@@ -0,0 +1,10 @@
+/**
+ * Client-namespace projection of the turn-outline domain: a pure re-export
+ * of the package's types outlet. Client code imports ONLY the client
+ * namespace (repo discipline), so `./client` projects the same single-source
+ * content `./types` serves to host consumers — zero duplication.
+ *
+ * @module @deepseek-ai/dsh-session-turn-outline/client
+ */
+
+export type * from './types.ts'
diff --git a/packages/session/session-turn-outline/src/index.ts b/packages/session/session-turn-outline/src/index.ts
new file mode 100644
index 0000000000..0105e7c186
--- /dev/null
+++ b/packages/session/session-turn-outline/src/index.ts
@@ -0,0 +1,29 @@
+/**
+ * Function plugin registering the `turnOutline` projection unit: the
+ * whole-log turn outline (turn number, `turn/start` seq, bounded prompt
+ * preview) served through the session-projection seam — registry snapshot,
+ * change feed, and every projection carrier — so a client can offer every
+ * turn of a session and target history paging at exact seqs without holding
+ * the events. The plugin owns only the fold; delivery is the seam's.
+ *
+ * @module @deepseek-ai/dsh-session-turn-outline
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import { turnOutlineProjectionDefinition } from './projection.ts'
+
+export type * from './types.ts'
+
+/** Cordis plugin name. */
+export const name = 'session-turn-outline'
+/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */
+export const inject = ['sessionProjections']
+
+/**
+ * Register the `turnOutline` unit; the registration is an effect on this
+ * plugin's fiber, so unloading removes the key.
+ * @param ctx - registrant context carrying the projection registry.
+ */
+export function apply(ctx: Context): void {
+ ctx.sessionProjections.register(turnOutlineProjectionDefinition)
+}
diff --git a/packages/session/session-turn-outline/src/invariant.ts b/packages/session/session-turn-outline/src/invariant.ts
new file mode 100644
index 0000000000..82f1c94ccc
--- /dev/null
+++ b/packages/session/session-turn-outline/src/invariant.ts
@@ -0,0 +1,35 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-session-turn-outline`.
+ * @module @deepseek-ai/dsh-session-turn-outline/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-session-turn-outline'
+
+/** Cordis companion plugin name. */
+export const name = 'session-turn-outline-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the package owns a single pure projection fold whose
+ * wire payload is schema-validated by the projection registry at every
+ * snapshot and change-feed emission (including the strictly-increasing turn
+ * order the fold maintains), and the event relations the fold relies on
+ * (host-assigned monotonic turn numbers on `turn/start`, the turn's prompt
+ * `user/message` logged after its boundary) are owned and runtime-checked by
+ * dsh-agent-loop and the session surface, not here.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */
diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts
new file mode 100644
index 0000000000..53a405134e
--- /dev/null
+++ b/packages/session/session-turn-outline/src/projection.ts
@@ -0,0 +1,91 @@
+/**
+ * The `turnOutline` projection unit: a pure fold of `turn/start` boundaries
+ * and first human prompts into the whole-log turn outline the chat rail
+ * renders for turns outside a client's paged event window.
+ *
+ * `turn/start` — not the prompt `user/message` — anchors each entry because
+ * its seq is the load-through target for a jump: the loop logs `turn/start`
+ * before the turn's prompt and steps, so a window paged back through that seq
+ * contains the whole turn. The preview mirrors the rail's loaded-turn preview
+ * (space-joined text blocks, collapsed whitespace, 160-character cap) so a
+ * turn shows the same words before and after its events load.
+ *
+ * @module @deepseek-ai/dsh-session-turn-outline/projection
+ */
+
+import { z } from 'zod'
+import type { ZodType } from 'zod'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
+import type { TurnOutlineProjection } from './types.ts'
+
+/** Preview budget per entry, matching the rail's loaded-turn preview clamp. */
+const PREVIEW_LIMIT = 160
+
+/** Space-join text blocks until the budget is met, then normalize and cap. */
+function promptPreview(content: SessionEvent<'user/message'>['data']['content']): string {
+ let text = ''
+ for (const block of content) {
+ if (block.type !== 'text') continue
+ text += text === '' ? block.text : ` ${block.text}`
+ if (text.length >= PREVIEW_LIMIT) break
+ }
+ return text.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_LIMIT)
+}
+
+const turnOutlineSchema: ZodType = z.object({
+ turns: z.array(z.object({
+ turn: z.number().int().nonnegative(),
+ seq: z.number().int().nonnegative(),
+ prompt: z.string().max(PREVIEW_LIMIT),
+ }).strict()),
+}).strict().superRefine((state, context) => {
+ let previous = -1
+ for (const entry of state.turns) {
+ if (entry.turn <= previous) {
+ context.addIssue({ code: 'custom', message: 'turn outline entries must be strictly increasing by turn' })
+ return
+ }
+ previous = entry.turn
+ }
+})
+
+const EMPTY_OUTLINE: TurnOutlineProjection = { turns: [] }
+
+/** The `turnOutline` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
+export const turnOutlineProjectionDefinition = {
+ key: 'turnOutline',
+ stateVersion: 1,
+ stateSchema: turnOutlineSchema,
+ init: () => EMPTY_OUTLINE,
+ apply: (state, event) => {
+ // Every uninteresting event returns the same reference (Object.is gates the change feed).
+ switch (event.type) {
+ case 'turn/start': {
+ const last = state.turns.at(-1)
+ // Order guard: a boundary that does not advance the turn number keeps
+ // the outline sorted, and a retried turn's prompt lands on the
+ // standing entry.
+ if (last !== undefined && event.data.turn <= last.turn) return state
+ return { turns: [...state.turns, { turn: event.data.turn, seq: event.seq, prompt: '' }] }
+ }
+ case 'user/message': {
+ // Only the newest turn can still be waiting for its opening human
+ // prompt; later human messages in the same turn (steering) keep the
+ // first preview.
+ if (event.data.source.kind !== 'user') return state
+ const last = state.turns.at(-1)
+ if (last === undefined || last.prompt !== '') return state
+ const prompt = promptPreview(event.data.content)
+ if (prompt === '') return state
+ return { turns: [...state.turns.slice(0, -1), { ...last, prompt }] }
+ }
+ default:
+ return state
+ }
+ },
+ wire: {
+ viewSchema: turnOutlineSchema,
+ view: state => state,
+ },
+} satisfies ProjectionDefinition<'turnOutline', TurnOutlineProjection>
diff --git a/packages/session/session-turn-outline/src/types.ts b/packages/session/session-turn-outline/src/types.ts
new file mode 100644
index 0000000000..7746c58558
--- /dev/null
+++ b/packages/session/session-turn-outline/src/types.ts
@@ -0,0 +1,37 @@
+/**
+ * Pure types of the turn-outline domain: the ONE home of the `turnOutline`
+ * projection-key declaration, free of this package's host-side value imports
+ * (zod, the projection definition). Host consumers import `./types`; client
+ * aggregates import `./client`, which re-exports this module.
+ *
+ * @module @deepseek-ai/dsh-session-turn-outline/types
+ */
+
+export {}
+
+/** One started turn's outline facts, independent of what a client has paged in. */
+export interface TurnOutlineEntry {
+ /** Host-assigned turn number (the `turn/start` payload). */
+ readonly turn: number
+ /** The turn's `turn/start` event seq — paging a window back through this seq loads the whole turn. */
+ readonly seq: number
+ /** Bounded preview of the turn's first human prompt; `''` until an eligible prompt lands. */
+ readonly prompt: string
+}
+
+/** Whole-log turn outline: every started turn, strictly increasing by `turn`. */
+export interface TurnOutlineProjection {
+ /** Started turns in ascending turn order. */
+ readonly turns: readonly TurnOutlineEntry[]
+}
+
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ /** Whole-log turn outline fold state (identical to the wire view). */
+ turnOutline: TurnOutlineProjection
+ }
+ interface SessionProjectionMap {
+ /** Every started turn with its `turn/start` seq and bounded prompt preview; see {@link TurnOutlineProjection}. */
+ turnOutline: TurnOutlineProjection
+ }
+}
diff --git a/packages/session/session-turn-outline/tests/loader-composition.spec.ts b/packages/session/session-turn-outline/tests/loader-composition.spec.ts
new file mode 100644
index 0000000000..9568cb83c6
--- /dev/null
+++ b/packages/session/session-turn-outline/tests/loader-composition.spec.ts
@@ -0,0 +1,88 @@
+/**
+ * REAL-composition proof: the shipped YAML shape (session + projection
+ * registry + session-turn-outline) boots through the vendored Loader, the
+ * function plugin's namespace survives (no default export), and a logged turn
+ * with its prompt serves the outline through the composed registry.
+ */
+
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline'
+
+let root: string | undefined
+let context: Context | undefined
+
+afterEach(async () => {
+ await context?.fiber.dispose()
+ context = undefined
+ if (root !== undefined) await rm(root, { recursive: true, force: true })
+ root = undefined
+})
+
+async function loadYaml(lines: readonly string[]): Promise {
+ root = await mkdtemp(join(tmpdir(), 'dsh-session-turn-outline-loader-'))
+ const configPath = join(root, 'cordis.yml')
+ await writeFile(configPath, [...lines, ''].join('\n'))
+
+ context = new Context()
+ context.baseUrl = pathToFileURL(root).href + '/'
+ await context.plugin(Loader)
+ context.loader.builtins.include = Include
+ const modules = new Map([
+ ['@deepseek-ai/dsh-session', SessionStore],
+ ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
+ ['@deepseek-ai/dsh-session-turn-outline', SessionTurnOutlinePlugin],
+ ])
+ context.loader.internal = {
+ version: 'v2',
+ async import(specifier: string) {
+ if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
+ return modules.get(specifier)
+ },
+ } as unknown as NonNullable
+ await context.loader.create({
+ name: 'cordis:include',
+ config: { path: pathToFileURL(configPath).href },
+ })
+ await context.loader.await()
+ return context
+}
+
+describe('real Loader composition', () => {
+ it('loads the shipped session-turn-outline YAML shape and serves the outline', async () => {
+ const loaded = await loadYaml([
+ "- name: '@deepseek-ai/dsh-session'",
+ "- name: '@deepseek-ai/dsh-session-projection'",
+ "- name: '@deepseek-ai/dsh-session-turn-outline'",
+ ])
+
+ const unloaded = [...loaded.loader.entries()]
+ .filter(entry => entry.fiber === undefined && !entry.disabled)
+ .map(entry => entry.options.name)
+ expect(unloaded).toEqual([])
+
+ const session = loaded.sessions.create(SessionId('composed'))
+ const boundary = session.append('turn/start', { turn: 1 }).seq
+ session.append('user/message', createUserMessage({
+ content: [{ type: 'text', text: 'composed prompt' }],
+ source: { kind: 'user' },
+ }), { surfaceOp: 'append' })
+ expect(loaded.sessionProjections.snapshot(session).values.turnOutline)
+ .toEqual({ turns: [{ turn: 1, seq: boundary, prompt: 'composed prompt' }] })
+ })
+
+ it('keeps the function-plugin namespace free of a default export', () => {
+ // A default export beside the named form makes the Loader discard the
+ // namespace (postmortem 0001) — pin its absence.
+ expect('default' in SessionTurnOutlinePlugin).toBe(false)
+ })
+})
diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts
new file mode 100644
index 0000000000..80703ce778
--- /dev/null
+++ b/packages/session/session-turn-outline/tests/projection.spec.ts
@@ -0,0 +1,165 @@
+/**
+ * The `turnOutline` projection unit: mounting the plugin beside the
+ * projection registry serves the whole-log turn outline (turn number,
+ * `turn/start` seq, bounded first-prompt preview); compositions without the
+ * registry are unaffected; unmounting the plugin removes the key (HMR
+ * safety). Narrow fold paths with fabricated envelopes (non-human sources,
+ * regressive turn numbers) run against the exported definition directly.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline'
+import { turnOutlineProjectionDefinition } from '@deepseek-ai/dsh-session-turn-outline/src/projection.ts'
+import type { TurnOutlineProjection } from '@deepseek-ai/dsh-session-turn-outline/types'
+
+async function harness(withOutlinePlugin: boolean): Promise<{ ctx: Context; session: Session }> {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ if (withOutlinePlugin) await ctx.plugin(SessionTurnOutlinePlugin)
+ return { ctx, session: ctx.sessions.create(SessionId('outlined')) }
+}
+
+/** Append one human prompt; returns its seq. */
+function appendPrompt(session: Session, text: string): number {
+ return session.append('user/message', createUserMessage({
+ content: [{ type: 'text', text }],
+ source: { kind: 'user' },
+ }), { surfaceOp: 'append' }).seq
+}
+
+function outlineOf(ctx: Context, session: Session): TurnOutlineProjection {
+ return ctx.sessionProjections.snapshot(session).values.turnOutline as TurnOutlineProjection
+}
+
+describe('turn outline projection unit', () => {
+ it('serves an empty outline before any turn starts', async () => {
+ const { ctx, session } = await harness(true)
+ expect(outlineOf(ctx, session)).toEqual({ turns: [] })
+ expect(ctx.sessionProjections.checkpoint(session).turnOutline)
+ .toEqual({ ver: 1, seq: -1, val: { turns: [] } })
+ })
+
+ it('folds each started turn with its boundary seq and first human prompt only', async () => {
+ const { ctx, session } = await harness(true)
+ const firstBoundary = session.append('turn/start', { turn: 1 }).seq
+ appendPrompt(session, 'hello world')
+ appendPrompt(session, 'a later steer must not replace the prompt')
+ const secondBoundary = session.append('turn/start', { turn: 2 }).seq
+ appendPrompt(session, 'second prompt')
+ expect(outlineOf(ctx, session)).toEqual({
+ turns: [
+ { turn: 1, seq: firstBoundary, prompt: 'hello world' },
+ { turn: 2, seq: secondBoundary, prompt: 'second prompt' },
+ ],
+ })
+ })
+
+ it('keeps an empty preview for a turn whose prompt never lands', async () => {
+ const { ctx, session } = await harness(true)
+ const boundary = session.append('turn/start', { turn: 1 }).seq
+ session.append('step/start', { turn: 1, step: 1 })
+ expect(outlineOf(ctx, session)).toEqual({ turns: [{ turn: 1, seq: boundary, prompt: '' }] })
+ })
+
+ it('collapses whitespace, joins text blocks, and caps the preview at 160 characters', async () => {
+ const { ctx, session } = await harness(true)
+ session.append('turn/start', { turn: 1 })
+ session.append('user/message', createUserMessage({
+ content: [
+ { type: 'text', text: ` first\n\nline\t${'x'.repeat(200)}` },
+ { type: 'text', text: 'never reached past the budget' },
+ ],
+ source: { kind: 'user' },
+ }), { surfaceOp: 'append' })
+ const preview = outlineOf(ctx, session).turns[0]?.prompt
+ expect(preview).toBeDefined()
+ expect(preview).toMatch(/^first line x+$/)
+ expect(preview).toHaveLength(160)
+ })
+
+ it('ignores non-human user/message sources and pre-turn prompts', async () => {
+ const { ctx, session } = await harness(true)
+ appendPrompt(session, 'queued before any turn')
+ session.append('turn/start', { turn: 1 })
+ session.append('user/message', createUserMessage({
+ content: [{ type: 'text', text: 'injected context' }],
+ source: { kind: 'plugin', plugin: 'test-injector', form: 'relay' },
+ }), { surfaceOp: 'append' })
+ expect(outlineOf(ctx, session)).toEqual({
+ turns: [{ turn: 1, seq: 1, prompt: '' }],
+ })
+ })
+
+ it('notifies the change feed only when the outline actually moves', async () => {
+ const { ctx, session } = await harness(true)
+ const changes: { key: string; seq: number }[] = []
+ ctx.sessionProjections.onChanged((_session, key, _value, seq) => {
+ if (key === 'turnOutline') changes.push({ key, seq })
+ })
+ const boundarySeq = session.append('turn/start', { turn: 1 }).seq
+ session.append('step/start', { turn: 1, step: 1 })
+ const promptSeq = appendPrompt(session, 'hello')
+ appendPrompt(session, 'second human message in the same turn')
+ session.append('step/end', { turn: 1, step: 1 })
+ expect(changes).toEqual([
+ { key: 'turnOutline', seq: boundarySeq },
+ { key: 'turnOutline', seq: promptSeq },
+ ])
+ })
+
+ it('skips a boundary that does not advance the turn number (fabricated envelope)', () => {
+ const state: TurnOutlineProjection = { turns: [{ turn: 2, seq: 5, prompt: 'kept' }] }
+ const regressive = {
+ type: 'turn/start',
+ seq: 9,
+ time: 0,
+ data: { turn: 2 },
+ } as unknown as SessionEvent
+ expect(turnOutlineProjectionDefinition.apply(state, regressive)).toBe(state)
+ })
+
+ it('folds turns already in the log when the plugin mounts late (lazy cell build)', async () => {
+ const { ctx, session } = await harness(false)
+ session.append('turn/start', { turn: 1 })
+ appendPrompt(session, 'pre-mount prompt')
+ await ctx.plugin(SessionTurnOutlinePlugin)
+ expect(outlineOf(ctx, session).turns).toEqual([{ turn: 1, seq: 0, prompt: 'pre-mount prompt' }])
+ })
+
+ it('has no key without the plugin and drops it when the plugin unloads (HMR safety)', async () => {
+ const { ctx, session } = await harness(false)
+ expect('turnOutline' in ctx.sessionProjections.snapshot(session).values).toBe(false)
+ const fiber = await ctx.plugin(SessionTurnOutlinePlugin)
+ session.append('turn/start', { turn: 1 })
+ expect('turnOutline' in ctx.sessionProjections.snapshot(session).values).toBe(true)
+ await fiber.dispose()
+ expect('turnOutline' in ctx.sessionProjections.snapshot(session).values).toBe(false)
+ })
+
+ it('rejects a persisted checkpoint whose turns are not strictly increasing', async () => {
+ const { ctx, session } = await harness(true)
+ const checkpoint = ctx.sessionProjections.checkpoint(session)
+ const row = checkpoint.turnOutline
+ expect(row).toBeDefined()
+ expect(() => ctx.sessionProjections.restore({
+ ...checkpoint,
+ turnOutline: {
+ ...row!,
+ val: { turns: [{ turn: 2, seq: 1, prompt: '' }, { turn: 2, seq: 4, prompt: '' }] },
+ },
+ }, [], 0, session.header)).toThrow(/strictly increasing/)
+ expect(() => ctx.sessionProjections.restore({
+ ...checkpoint,
+ turnOutline: {
+ ...row!,
+ val: { turns: [{ turn: 1, seq: 1, prompt: 'ok' }, { turn: 2, seq: 4, prompt: '' }] },
+ },
+ }, [], 0, session.header)).not.toThrow()
+ })
+})
diff --git a/packages/session/session-turn-outline/tsconfig.json b/packages/session/session-turn-outline/tsconfig.json
new file mode 100644
index 0000000000..c87ec78f8b
--- /dev/null
+++ b/packages/session/session-turn-outline/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../runtime-diagnostics/invariants"
+ },
+ {
+ "path": "../../llm/llm"
+ },
+ {
+ "path": "../../core/session"
+ },
+ {
+ "path": "../session-projection"
+ }
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 326595dfff..022f00b2f2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1680,6 +1680,9 @@ importers:
'@deepseek-ai/dsh-session-stats':
specifier: workspace:^
version: link:../../session/session-stats
+ '@deepseek-ai/dsh-session-turn-outline':
+ specifier: workspace:^
+ version: link:../../session/session-turn-outline
'@deepseek-ai/dsh-subprocess':
specifier: workspace:^
version: link:../../subprocess/subprocess
@@ -7796,6 +7799,34 @@ importers:
specifier: workspace:^
version: link:../../util/timeout
+ packages/session/session-turn-outline:
+ dependencies:
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:^
+ version: link:../../../vendor/cordis
+ '@deepseek-ai/cordis-plugin-include':
+ specifier: workspace:^
+ version: link:../../../vendor/include
+ '@deepseek-ai/cordis-plugin-loader':
+ specifier: workspace:^
+ version: link:../../../vendor/loader
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../runtime-diagnostics/invariants
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:^
+ version: link:../../llm/llm
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../session-projection
+
packages/settings/settings:
dependencies:
'@deepseek-ai/dsh-util-values':
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index cb30c6a387..3b02f00d6b 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -141,6 +141,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/session/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers nothing model-facing.' },
'packages/session/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers nothing model-facing.' },
'packages/session/session-stats': { kind: 'none', reason: 'The sessionStats unit folds already-logged step boundaries into a client-facing read model and registers nothing model-facing.' },
+ 'packages/session/session-turn-outline': { kind: 'none', reason: 'The turnOutline unit folds already-logged turn boundaries into a client-facing read model and registers nothing model-facing.' },
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers nothing model-facing.' },
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers nothing model-facing.' },
'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model-facing content fed by a value.' },
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 3c6bc168d0..5a9bc4a4b0 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -79,6 +79,8 @@
"@deepseek-ai/dsh-util-workspace-path": ["./packages/util/workspace-path/src/index.ts"],
"@deepseek-ai/dsh-session-stats/types": ["./packages/session/session-stats/src/types.ts"],
"@deepseek-ai/dsh-session-stats/client": ["./packages/session/session-stats/src/client.ts"],
+ "@deepseek-ai/dsh-session-turn-outline/types": ["./packages/session/session-turn-outline/src/types.ts"],
+ "@deepseek-ai/dsh-session-turn-outline/client": ["./packages/session/session-turn-outline/src/client.ts"],
"@deepseek-ai/dsh-token-meter/client": ["./packages/llm/token-meter/src/client.ts"],
"@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"],
"@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"],
@@ -438,6 +440,8 @@
"@deepseek-ai/dsh-session-title-first-prompt-llm/invariant": ["./packages/session/session-title-first-prompt-llm/src/invariant.ts"],
"@deepseek-ai/dsh-session-title-llm": ["./packages/session/session-title-llm/src"],
"@deepseek-ai/dsh-session-title-llm/invariant": ["./packages/session/session-title-llm/src/invariant.ts"],
+ "@deepseek-ai/dsh-session-turn-outline": ["./packages/session/session-turn-outline/src"],
+ "@deepseek-ai/dsh-session-turn-outline/invariant": ["./packages/session/session-turn-outline/src/invariant.ts"],
"@deepseek-ai/dsh-settings": ["./packages/settings/settings/src"],
"@deepseek-ai/dsh-settings/invariant": ["./packages/settings/settings/src/invariant.ts"],
"@deepseek-ai/dsh-settings-file": ["./packages/settings/settings-file/src"],
diff --git a/tsconfig.host.json b/tsconfig.host.json
index e21c47c805..2d9dc3513c 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -180,6 +180,7 @@
{ "path": "./packages/session/session-title-llm" },
{ "path": "./packages/session/session-title-first-prompt-llm" },
{ "path": "./packages/session/session-title-all-prompts-llm" },
+ { "path": "./packages/session/session-turn-outline" },
{ "path": "./packages/session/session-telemetry" },
{ "path": "./packages/identity/anonymous-user-id" },
{ "path": "./packages/session/session-telemetry-otel" },
From 218bb7f6452793de50c53e39cdbb5e8db463a4b1 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 12:54:52 +0800
Subject: [PATCH 05/26] feat(session-controller): loadThrough deep history
paging
Session.loadThrough(seq) loops the existing prepend pager (200-message
pages) until the window covers the target seq, with a shared low-water
retarget for repeated calls, a no-progress guard against empty pages
still claiming history, and loadOlder's fail-soft error posture. Busy
state rides the existing loadingOlder snapshot bit.
---
.../src/client/contract/session.ts | 9 ++
.../src/client/sessions/session.ts | 42 +++++++++
.../tests/session.client.spec.ts | 90 ++++++++++++++++++-
.../conversation-registry.client.spec.ts | 1 +
.../client-runtime/src/sessions.ts | 8 ++
5 files changed, 149 insertions(+), 1 deletion(-)
diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts
index 02214bb872..71cc72e0ab 100644
--- a/packages/api/session-controller/src/client/contract/session.ts
+++ b/packages/api/session-controller/src/client/contract/session.ts
@@ -119,6 +119,15 @@ export interface ISession {
* @returns completion; failures land in snapshot.openState/loadingOlder.
*/
loadOlder(): Promise
+ /**
+ * Page history backwards until the window covers `seq` (inclusive) — the
+ * turn-jump loader. Repeated calls while a jump is paging lower its shared
+ * target and return the in-flight completion; `snapshot.loadingOlder` is
+ * the busy signal for the whole jump.
+ * @param seq - durable event seq the window must reach (a turn's `turn/start` seq).
+ * @returns completion once covered, exhausted, superseded, or failed soft.
+ */
+ loadThrough(seq: number): Promise
/**
* Execute one slash-command line against this session's agent — pure
* admission semantics (the host executor durably logs the lifecycle).
diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts
index 25d5e61082..294f33f3aa 100644
--- a/packages/api/session-controller/src/client/sessions/session.ts
+++ b/packages/api/session-controller/src/client/sessions/session.ts
@@ -38,6 +38,9 @@ import { SessionQueueMirror } from './queue-mirror.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
+/** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */
+export const JUMP_PAGE_MESSAGES = 200
+
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/** Catalog-discovered address selecting non-activating subagent transport. */
@@ -78,6 +81,10 @@ export class Session implements SessionFace {
* passes drop all writes once the generation moves on. */
private openGeneration = 0
private loadingOlder = false
+ /** Shared low-water target of the running jump loop; null when no jump is paging. */
+ private jumpTargetSeq: number | null = null
+ /** The running jump loop's completion, shared by retargeting callers. */
+ private jumpPromise: Promise | null = null
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private readonly queueMirror = new SessionQueueMirror()
private running = false
@@ -369,6 +376,41 @@ export class Session implements SessionFace {
}
}
+ /** Jump loader: page backwards until the window covers seq (see ISession.loadThrough). */
+ loadThrough(seq: number): Promise {
+ if (this.openState !== 'open' || !this.hasMore || this.baseSeq <= seq) return Promise.resolve()
+ this.jumpTargetSeq = Math.min(this.jumpTargetSeq ?? seq, seq)
+ if (this.jumpPromise !== null) return this.jumpPromise
+ // A plain single-page pull owns the busy flag; the jump does not queue
+ // behind it (the caller may retry once it settles).
+ if (this.loadingOlder) return Promise.resolve()
+ this.loadingOlder = true
+ this.notifier.markDirty()
+ this.jumpPromise = (async () => {
+ try {
+ while (this.hasMore && this.jumpTargetSeq !== null && this.baseSeq > this.jumpTargetSeq) {
+ const events = this.events
+ if (events === undefined) return
+ const before = this.baseSeq
+ await events.prepend({ beforeSeq: this.baseSeq, maxMessages: JUMP_PAGE_MESSAGES })
+ // No-progress guard: an empty or dropped page that still claims more
+ // history must end the loop, not spin it.
+ if (this.baseSeq >= before) return
+ }
+ } catch (error) {
+ if (!isRemoteFailure(error)) {
+ console.error('[session-controller] loadThrough failed:', error)
+ }
+ } finally {
+ this.jumpTargetSeq = null
+ this.jumpPromise = null
+ this.loadingOlder = false
+ this.notifier.markDirty()
+ }
+ })()
+ return this.jumpPromise
+ }
+
/** Rebuild an opened history source after address replacement.
* Invalidates any in-flight open first; queue state belongs to the independently
* reconnecting control stream and remains untouched. */
diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts
index 319c315873..2bee7d33b4 100644
--- a/packages/api/session-controller/tests/session.client.spec.ts
+++ b/packages/api/session-controller/tests/session.client.spec.ts
@@ -5,7 +5,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
-import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
+import { JUMP_PAGE_MESSAGES, Session, type SessionOptions } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
import { entries, ev, historyValue, plainTurn } from './event-script.client.ts'
@@ -254,6 +254,94 @@ describe('paging', () => {
}
})
+ it('loadThrough pages repeatedly until the window covers the target seq', async () => {
+ const oldest = plainTurn(0, 0, '最旧问', '最旧答')
+ const middle = plainTurn(6, 1, '中问', '中答')
+ const newest = plainTurn(12, 2, '新问', '新答')
+ const { api, session } = makeSession()
+ api.onHistory = (payload) => {
+ if (payload.beforeSeq === undefined) return histResponse(newest, true)
+ return payload.beforeSeq === 12 ? histResponse(middle, true) : histResponse(oldest, false)
+ }
+ await session.open()
+
+ const gate = deferred>>()
+ api.onHistory = (payload) => {
+ api.onHistory = payload2 => payload2.beforeSeq === 12 ? histResponse(middle, true) : histResponse(oldest, false)
+ void payload
+ return gate.promise
+ }
+ const jump = session.loadThrough(0)
+ expect(session.getSnapshot().loadingOlder).toBe(true)
+ gate.resolve(ok(historyValue(middle, true)))
+ await jump
+ const snapshot = session.getSnapshot()
+ expect(snapshot.loadingOlder).toBe(false)
+ expect(eventSeqs(session)).toEqual([...oldest, ...middle, ...newest].map(event => event.seq))
+ expect(api.callsOf('session.history')).toMatchObject([
+ { beforeSeq: 12, maxMessages: JUMP_PAGE_MESSAGES },
+ { beforeSeq: 6, maxMessages: JUMP_PAGE_MESSAGES },
+ ])
+ })
+
+ it('loadThrough is a no-op when the window already covers the target or the session is not open', async () => {
+ const { api, session } = makeSession()
+ await session.loadThrough(0) // cold: no-op
+ expect(api.calls).toEqual([])
+ api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
+ await session.open()
+ const calls = api.calls.length
+ await session.loadThrough(6) // baseSeq is already 6
+ await session.loadThrough(9) // inside the window
+ expect(api.calls.length).toBe(calls)
+ })
+
+ it('loadThrough retargets a running jump to the lowest requested seq and shares its completion', async () => {
+ const oldest = plainTurn(0, 0, 'a', 'b')
+ const middle = plainTurn(6, 1, 'c', 'd')
+ const { api, session } = makeSession()
+ api.onHistory = () => histResponse(plainTurn(12, 2, 'e', 'f'), true)
+ await session.open()
+
+ const gate = deferred>>()
+ api.onHistory = () => {
+ api.onHistory = () => histResponse(oldest, false)
+ return gate.promise
+ }
+ const first = session.loadThrough(6)
+ const second = session.loadThrough(0)
+ gate.resolve(ok(historyValue(middle, true)))
+ await Promise.all([first, second])
+ expect(eventSeqs(session)).toEqual([...oldest, ...middle].map(event => event.seq).concat([12, 13, 14, 15, 16, 17]))
+ expect(api.callsOf('session.history')).toHaveLength(2)
+ })
+
+ it('loadThrough stops on a page that makes no progress instead of looping', async () => {
+ const { api, session } = makeSession()
+ api.onHistory = payload => payload.beforeSeq === undefined
+ ? histResponse(plainTurn(12, 2, 'x', 'y'), true)
+ : histResponse([], true) // empty page still claiming more history
+ await session.open()
+ await session.loadThrough(0)
+ expect(session.getSnapshot().loadingOlder).toBe(false)
+ expect(api.callsOf('session.history')).toHaveLength(1)
+ })
+
+ it('loadThrough fails soft on a thrown page and clears its busy state', async () => {
+ const { api, session } = makeSession()
+ api.onHistory = () => histResponse(plainTurn(12, 2, 'x', 'y'), true)
+ await session.open()
+ api.onHistory = () => Promise.reject(new Error('page wire down'))
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ try {
+ await session.loadThrough(0)
+ expect(errorSpy).toHaveBeenCalled()
+ expect(session.getSnapshot().loadingOlder).toBe(false)
+ } finally {
+ errorSpy.mockRestore()
+ }
+ })
+
it('ignores loadOlder while one is in flight (single request)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
index 63b2c22146..ba52e36ba6 100644
--- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
+++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
@@ -51,6 +51,7 @@ function fakeSession(): SessionFace {
cancel: () => Promise.reject(new Error('unused fake Session operation')),
rename: () => Promise.reject(new Error('unused fake Session operation')),
loadOlder: () => Promise.reject(new Error('unused fake Session operation')),
+ loadThrough: () => Promise.reject(new Error('unused fake Session operation')),
command: () => Promise.reject(new Error('unused fake Session operation')),
}
}
diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts
index da67718820..419e155af7 100644
--- a/packages/test-support/client-runtime/src/sessions.ts
+++ b/packages/test-support/client-runtime/src/sessions.ts
@@ -152,6 +152,14 @@ export class FixtureSession implements SessionFace {
throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
}
+ /**
+ * Fail-loud stub; supply `loadThrough` on the fixture's session face to exercise it.
+ * @returns never — always throws.
+ */
+ loadThrough(): never {
+ throw new Error(`test session "${this.sessionId}": loadThrough is not stubbed — supply it on the fixture's session face`)
+ }
+
/**
* Fail-loud stub; supply `rename` on the fixture's session face to exercise it.
* @returns never — always throws.
From b3064cca771aaa116190388e38946c06f7b8c062 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 13:08:14 +0800
Subject: [PATCH 06/26] feat(ui-chat): full-session turn rail with
load-and-jump
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The rail now merges the turnOutline projection with loaded-window items
(view-layer only; loaded wins, outline fills mid-turn prompt previews),
renders unloaded turns as dimmer marks, and clicking one holds the
reader's place, pages history through the turn's seq, and lands on its
row after the commit — no height estimation. Settlement repages once
per head movement, then falls back to the nearest rendered turn.
---
packages/client/ui-chat/package.json | 1 +
packages/client/ui-chat/src/client/apply.ts | 1 +
.../ui-chat/src/client/chat/ChatView.tsx | 138 +++++++++++++++---
.../src/client/chat/TurnNavigator.module.css | 17 +++
.../ui-chat/src/client/chat/TurnNavigator.tsx | 35 +++--
.../src/client/chat/turn-rail-items.ts | 79 ++++++++++
.../ui-chat/src/client/contract/slots.ts | 2 +
packages/client/ui-chat/src/client/locale.ts | 2 +
.../ui-chat/tests/chat-view.client.spec.tsx | 69 ++++++++-
.../tests/turn-rail-items.client.spec.ts | 74 ++++++++++
packages/client/ui-chat/tsconfig.json | 3 +
pnpm-lock.yaml | 3 +
12 files changed, 391 insertions(+), 33 deletions(-)
create mode 100644 packages/client/ui-chat/src/client/chat/turn-rail-items.ts
create mode 100644 packages/client/ui-chat/tests/turn-rail-items.client.spec.ts
diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json
index 2121f5f804..56b3e2a888 100644
--- a/packages/client/ui-chat/package.json
+++ b/packages/client/ui-chat/package.json
@@ -78,6 +78,7 @@
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-stats": "workspace:^",
+ "@deepseek-ai/dsh-session-turn-outline": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts
index be061dff13..a23ada43d7 100644
--- a/packages/client/ui-chat/src/client/apply.ts
+++ b/packages/client/ui-chat/src/client/apply.ts
@@ -125,6 +125,7 @@ export function apply(ctx: Context): void {
if (!result.ok) throw new Error(`path open failed: ${result.error.message}`)
},
loadOlder: () => { void session.loadOlder() },
+ loadThrough: seq => session.loadThrough(seq),
loadImage: Object.assign(
(attachment: ImageAttachmentRef) => ctx.uiConversation.imageUrl(sessionId, attachment),
{ peek: (attachment: ImageAttachmentRef) => ctx.uiConversation.peekImageUrl(sessionId, attachment) },
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index 517dccfeb3..2c41dbd057 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -7,10 +7,11 @@ import type {
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
-import type { ChatSnapshot, TurnNavigationItem } from '../contract/snapshot.ts'
+import type { ChatSnapshot } from '../contract/snapshot.ts'
import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
import { TurnNavigator } from './TurnNavigator.tsx'
+import { mergeTurnRailItems, type TurnRailItem } from './turn-rail-items.ts'
import { formatRunDuration } from './message-chrome.ts'
import css from './ChatView.module.css'
@@ -202,8 +203,8 @@ function TurnStatus({ startTime, t }: {
* ordered business Node crosses the keyed renderer seat.
*/
export function ChatView({
- useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt,
- fileMentions, useTranscriptView, t,
+ useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadThrough,
+ loadImage, openView, chatScroll, forkAt, fileMentions, useTranscriptView, useProjection, t,
}: ChatViewSlotProps) {
const order = useChat(s => s.order)
const nodeStore = useChat(s => s.nodes)
@@ -211,6 +212,13 @@ export function ChatView({
// both the data and its change signal: the array identity moves only when a
// Turn enters, leaves, or changes its preview.
const turnNavigationItems = useChat(s => s.navigation.items())
+ // Host-computed whole-log outline; the merge is view-layer only (the
+ // conversation snapshot never carries projection values).
+ const turnOutline = useProjection('turnOutline')
+ const railItems = useMemo(
+ () => mergeTurnRailItems(turnNavigationItems, turnOutline),
+ [turnNavigationItems, turnOutline],
+ )
const timeline = useChat(s => s.timeline)
const inbox = useSession(s => s.queue)
// Workspace root off the session list row: path summaries display relative to it.
@@ -295,6 +303,13 @@ export function ChatView({
/** Paging anchor: semantic row/position at click, updated by reader scrolls
* while the request is pending and restored after the prepend lands. */
const anchorRef = useRef(null)
+ /** Unloaded-turn jump in flight: target turn plus its load-through seq. */
+ const pendingJumpRef = useRef<{ turn: number; seq: number } | null>(null)
+ const [busyJumpTurn, setBusyJumpTurn] = useState(null)
+ /** Bumped when a loadThrough completion settles, after its last page's commit. */
+ const [jumpSettleTick, setJumpSettleTick] = useState(0)
+ /** Window head at the last settle-time repage; an unmoved head falls back instead of repaging forever. */
+ const jumpRepageHeadRef = useRef(null)
const firstSeqRef = useRef(null)
const openedRef = useRef(false)
const lastKeyRef = useRef(null)
@@ -369,6 +384,9 @@ export function ChatView({
const toBottom = (el: HTMLElement): void => {
anchorRef.current = null
+ // Returning to the live tail supersedes a jump still landing.
+ pendingJumpRef.current = null
+ setBusyJumpTurn(current => current === null ? current : null)
el.scrollTop = el.scrollHeight
observedTopRef.current = el.scrollTop
atBottomRef.current = true
@@ -377,6 +395,38 @@ export function ChatView({
setActiveTurn(turnNavigationItems.at(-1)?.turn ?? null)
}
+ // Land a row at the reading line and republish scroll-derived state. A
+ // latest-ref, so navigateToTurn's identity stays stable for the memoized rail.
+ const landOnRowRef = useRef<(local: HTMLElement, el: HTMLElement, row: HTMLElement, turn: number) => void>(
+ () => {},
+ )
+ landOnRowRef.current = (local, el, row, turn) => {
+ el.scrollTop += flowTop(row, el) - 24
+ observedTopRef.current = el.scrollTop
+ const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
+ atBottomRef.current = isAtBottom
+ setAtBottom(isAtBottom)
+ setActiveTurn(turn)
+ const position = isAtBottom ? null : scrollPosition(local, el)
+ if (isAtBottom) chatScroll.save(null)
+ else if (position !== null) chatScroll.save(position)
+ }
+
+ /** Land the pending jump once its Turn has a rendered anchor row; false while it must keep waiting. */
+ const realizePendingJump = (local: HTMLElement, el: HTMLElement): boolean => {
+ const pending = pendingJumpRef.current
+ if (pending === null) return true
+ const item = railItems.find(candidate => candidate.turn === pending.turn)
+ if (item === undefined || item.anchor.kind !== 'loaded') return false
+ const row = anchorElement(local, item.anchor.key)
+ if (row === null) return false
+ pendingJumpRef.current = null
+ setBusyJumpTurn(null)
+ anchorRef.current = null
+ landOnRowRef.current(local, el, row, pending.turn)
+ return true
+ }
+
useLayoutEffect(() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
@@ -418,6 +468,11 @@ export function ChatView({
const row = anchorElement(local, anchor.key)
if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top
observedTopRef.current = el.scrollTop
+ // A jump chunk lands here: scroll to the target once its rows exist;
+ // until then keep holding the reader's row for the next chunk.
+ if (!realizePendingJump(local, el) && row !== null) {
+ anchorRef.current = { key: anchor.key, top: flowTop(row, el) }
+ }
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastKey
@@ -439,7 +494,13 @@ export function ChatView({
followSigRef.current = followSig
// Follow new flow content while pinned; do NOT re-pin on every render
// merely because atBottomRef is true (scroll threshold → setState → snap).
- if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) toBottom(el)
+ if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) {
+ toBottom(el)
+ return
+ }
+ // A jump whose target committed outside the anchored-prepend path (for
+ // example after a mid-jump toBottom dropped the held anchor) lands here.
+ if (pendingJumpRef.current !== null) realizePendingJump(local, el)
})
const onScrollRef = useRef(() => {})
@@ -533,6 +594,38 @@ export function ChatView({
if (!loadingOlder) anchorRef.current = null
}, [loadingOlder])
+ // Jump settlement: every loadThrough completion bumps the tick after its
+ // last page's commit. A still-pending jump is either realized now, repaged
+ // once per head movement (its own paging can be refused while a plain pull
+ // holds the busy flag), or landed on the nearest rendered Turn at or after
+ // the target (failure, exhausted history, or a Turn with no visible row).
+ useEffect(() => {
+ const pending = pendingJumpRef.current
+ const local = listRef.current
+ if (pending === null || local === null) return
+ const el = scrollerOf(local)
+ if (realizePendingJump(local, el)) return
+ const uncovered = firstSeq === null || firstSeq > pending.seq
+ if (uncovered && hasMore && !loadingOlder && jumpRepageHeadRef.current !== firstSeq) {
+ jumpRepageHeadRef.current = firstSeq
+ const held = pagingAnchor(local, el)
+ if (held !== null && held.dataset.chatAnchorKey !== undefined) {
+ anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) }
+ }
+ void loadThrough(pending.seq).finally(() => { setJumpSettleTick(tick => tick + 1) })
+ return
+ }
+ for (const row of local.querySelectorAll('[data-chat-turn]:not([hidden])')) {
+ const turn = Number(row.dataset.chatTurn)
+ if (!Number.isSafeInteger(turn) || turn < pending.turn) continue
+ landOnRowRef.current(local, el, row, turn)
+ break
+ }
+ pendingJumpRef.current = null
+ setBusyJumpTurn(null)
+ // Snapshot values are read at settle time; the completion tick is the trigger.
+ }, [jumpSettleTick])
+
const loadOlderAnchored = (): void => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
@@ -550,35 +643,44 @@ export function ChatView({
}
// Identity feeds the memoized rail; a fresh closure per render would defeat it.
- const navigateToTurn = useCallback((item: TurnNavigationItem): void => {
+ const navigateToTurn = useCallback((item: TurnRailItem): void => {
const local = listRef.current
if (local === null) return
- const row = anchorElement(local, item.anchorKey)
- if (row === null) return
const el = scrollerOf(local)
- el.scrollTop += flowTop(row, el) - 24
- observedTopRef.current = el.scrollTop
+ if (item.anchor.kind === 'unloaded') {
+ // Hold the reader's place through the paging chunks; the layout effect
+ // lands on the target once its rows commit.
+ const held = pagingAnchor(local, el)
+ if (held !== null && held.dataset.chatAnchorKey !== undefined) {
+ anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) }
+ }
+ pendingJumpRef.current = { turn: item.turn, seq: item.anchor.seq }
+ jumpRepageHeadRef.current = null
+ setBusyJumpTurn(item.turn)
+ void loadThrough(item.anchor.seq).finally(() => { setJumpSettleTick(tick => tick + 1) })
+ return
+ }
+ const row = anchorElement(local, item.anchor.key)
+ if (row === null) return
+ // A loaded-mark click supersedes any jump still landing.
+ pendingJumpRef.current = null
+ setBusyJumpTurn(current => current === null ? current : null)
+ landOnRowRef.current(local, el, row, item.turn)
// A pending older page still has to compensate the prepended height, so
// navigation moves that anchor to the new position instead of dropping it.
const landed = loadingOlder ? pagingAnchor(local, el) : null
anchorRef.current = landed === null || landed.dataset.chatAnchorKey === undefined
? null
: { key: landed.dataset.chatAnchorKey, top: flowTop(landed, el) }
- const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
- atBottomRef.current = isAtBottom
- setAtBottom(isAtBottom)
- setActiveTurn(item.turn)
- const position = isAtBottom ? null : scrollPosition(local, el)
- if (isAtBottom) chatScroll.save(null)
- else if (position !== null) chatScroll.save(position)
- }, [loadingOlder, chatScroll])
+ }, [loadingOlder, loadThrough])
return (
diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css
index 76a7b73e9c..af13b07ba0 100644
--- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css
+++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css
@@ -83,11 +83,21 @@
transition: width 140ms ease, background-color 140ms ease;
}
+/* Ordered before hover/active so those states keep their stronger tick. */
+.markUnloaded::before {
+ width: 8px;
+ opacity: 0.6;
+}
+
.markPreview::before {
width: 18px;
background: var(--dsw-alias-label-tertiary);
}
+.markBusy::before {
+ animation: dsh-turn-mark-busy 1s ease-in-out infinite;
+}
+
.markActive::before {
width: 20px;
background: var(--dsw-alias-label-primary);
@@ -163,6 +173,12 @@
to { opacity: 1; transform: translateX(0); }
}
+@keyframes dsh-turn-mark-busy {
+ 0%,
+ 100% { opacity: 1; }
+ 50% { opacity: 0.35; }
+}
+
@container (max-width: 900px) {
.slot {
display: none;
@@ -173,6 +189,7 @@
.rail,
.markPosition,
.mark::before,
+ .markBusy::before,
.preview {
transition: none;
animation: none;
diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx
index 55818f85cd..3eef55269a 100644
--- a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx
+++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx
@@ -2,13 +2,15 @@ import {
memo, useId, useState, type CSSProperties, type MouseEvent, type PointerEvent,
} from 'react'
import type { ChatViewSlotProps } from '../contract/slots.ts'
-import type { TurnNavigationItem } from '../contract/snapshot.ts'
+import type { TurnRailItem } from './turn-rail-items.ts'
import css from './TurnNavigator.module.css'
interface TurnNavigatorProps {
- readonly items: readonly TurnNavigationItem[]
+ readonly items: readonly TurnRailItem[]
readonly activeTurn: number | null
- readonly onNavigate: (item: TurnNavigationItem) => void
+ /** Turn whose jump is still paging history in; its mark pulses. */
+ readonly busyTurn: number | null
+ readonly onNavigate: (item: TurnRailItem) => void
readonly t: ChatViewSlotProps['t']
}
@@ -43,17 +45,17 @@ function railSize(count: number): TurnRailStyle {
}
function itemAtPointer(
- items: readonly TurnNavigationItem[],
+ items: readonly TurnRailItem[],
rail: HTMLElement,
clientY: number,
-): TurnNavigationItem | undefined {
+): TurnRailItem | undefined {
const rect = rail.getBoundingClientRect()
const usableHeight = Math.max(1, rect.height - 2 * RAIL_INSET_PX)
const ratio = Math.max(0, Math.min(1, (clientY - rect.top - RAIL_INSET_PX) / usableHeight))
return items[Math.round(ratio * (items.length - 1))]
}
-function TurnNavigatorRail({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) {
+function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, t }: TurnNavigatorProps) {
const [previewTurn, setPreviewTurn] = useState
(null)
const previewId = useId()
if (items.length < 2) return null
@@ -81,16 +83,22 @@ function TurnNavigatorRail({ items, activeTurn, onNavigate, t }: TurnNavigatorPr
{items.map((item, index) => {
const active = item.turn === activeTurn
const showingPreview = item.turn === previewTurn
- const markClass = active
- ? `${css.mark} ${css.markActive}`
- : showingPreview ? `${css.mark} ${css.markPreview}` : css.mark
+ const classes = [css.mark]
+ if (item.anchor.kind === 'unloaded') classes.push(css.markUnloaded)
+ if (active) classes.push(css.markActive)
+ else if (showingPreview) classes.push(css.markPreview)
+ if (item.turn === busyTurn) classes.push(css.markBusy)
return (
{
event.stopPropagation()
@@ -117,9 +125,10 @@ function TurnNavigatorRail({ items, activeTurn, onNavigate, t }: TurnNavigatorPr
}
/**
- * Compact rail of the currently loaded Turns with hover and focus previews.
+ * Compact rail of every known Turn — loaded marks scroll, unloaded marks page
+ * history in first — with hover and focus previews.
*
- * Memoized because it renders two host elements per loaded Turn while the
+ * Memoized because it renders two host elements per Turn while the
* enclosing view re-renders on every streaming delta: without the guard a long
* session rebuilds hundreds of marks per commit for a rail that only changes
* when a Turn is added, removed, or becomes active. Its props must therefore
diff --git a/packages/client/ui-chat/src/client/chat/turn-rail-items.ts b/packages/client/ui-chat/src/client/chat/turn-rail-items.ts
new file mode 100644
index 0000000000..349041dac9
--- /dev/null
+++ b/packages/client/ui-chat/src/client/chat/turn-rail-items.ts
@@ -0,0 +1,79 @@
+/**
+ * View-layer union of the host turn outline and the loaded rail items. The
+ * conversation snapshot never carries projection values, so this merge is the
+ * one place the rail's two sources meet: the `turnOutline` projection names
+ * every turn of the session, and the loaded window supplies anchors and
+ * richer previews for the turns it holds.
+ */
+
+import type {} from '@deepseek-ai/dsh-session-turn-outline/client'
+import type { TurnNavigationItem } from '../contract/snapshot.ts'
+
+/** One rail mark: a loaded Turn scrolls to its row; an unloaded one pages history through its seq first. */
+export interface TurnRailItem {
+ readonly turn: number
+ /** Bounded prompt preview (loaded window first, outline fallback). */
+ readonly prompt: string
+ /** Bounded response preview; `''` for unloaded Turns (the outline carries prompts only). */
+ readonly response: string
+ /** How the rail reaches the Turn. */
+ readonly anchor:
+ | { readonly kind: 'loaded'; readonly key: string }
+ | { readonly kind: 'unloaded'; readonly seq: number }
+}
+
+const EMPTY_ITEMS: readonly TurnRailItem[] = []
+
+/** Structurally narrow one wire outline entry (projection values cross the wire). */
+function outlineEntry(value: unknown): { turn: number; seq: number; prompt: string } | undefined {
+ if (typeof value !== 'object' || value === null) return undefined
+ const entry = value as { turn?: unknown; seq?: unknown; prompt?: unknown }
+ if (typeof entry.turn !== 'number' || !Number.isSafeInteger(entry.turn) || entry.turn < 0) return undefined
+ if (typeof entry.seq !== 'number' || !Number.isSafeInteger(entry.seq) || entry.seq < 0) return undefined
+ if (typeof entry.prompt !== 'string') return undefined
+ return { turn: entry.turn, seq: entry.seq, prompt: entry.prompt }
+}
+
+/** Wire outline entries, or none when the projection is absent or malformed. */
+function outlineEntries(outline: unknown): readonly unknown[] {
+ if (typeof outline !== 'object' || outline === null) return EMPTY_ITEMS
+ const turns = (outline as { turns?: unknown }).turns
+ return Array.isArray(turns) ? turns : EMPTY_ITEMS
+}
+
+/**
+ * Merge the host outline with the loaded rail items into the full ladder.
+ * A turn present in both sides keeps the loaded anchor and response, taking
+ * the outline prompt only when the window started mid-Turn (empty loaded
+ * preview); turns on one side only pass through. Result ascends by turn.
+ * @param loaded - loaded-window rail items (timeline order).
+ * @param outline - `turnOutline` projection value, treated as wire data.
+ * @returns every known turn, ascending; a stable empty array when none.
+ */
+export function mergeTurnRailItems(
+ loaded: readonly TurnNavigationItem[],
+ outline: unknown,
+): readonly TurnRailItem[] {
+ const byTurn = new Map()
+ for (const raw of outlineEntries(outline)) {
+ const entry = outlineEntry(raw)
+ if (entry === undefined) continue
+ byTurn.set(entry.turn, {
+ turn: entry.turn,
+ prompt: entry.prompt,
+ response: '',
+ anchor: { kind: 'unloaded', seq: entry.seq },
+ })
+ }
+ for (const item of loaded) {
+ const preview = byTurn.get(item.turn)
+ byTurn.set(item.turn, {
+ turn: item.turn,
+ prompt: item.prompt !== '' ? item.prompt : preview?.prompt ?? '',
+ response: item.response,
+ anchor: { kind: 'loaded', key: item.anchorKey },
+ })
+ }
+ if (byTurn.size === 0) return EMPTY_ITEMS
+ return [...byTurn.values()].sort((left, right) => left.turn - right.turn)
+}
diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts
index 307d8f703b..14fe308321 100644
--- a/packages/client/ui-chat/src/client/contract/slots.ts
+++ b/packages/client/ui-chat/src/client/contract/slots.ts
@@ -118,6 +118,8 @@ export interface ChatViewInjected {
openDetails: (target: SelectionTarget) => void
openFile: (path: string) => Promise
loadOlder: () => void
+ /** Jump loader: page history back through seq; resolves when the window covers it. */
+ loadThrough: (seq: number) => Promise
loadImage: MessageImageLoader
chatScroll: {
save: (position: ChatScrollPosition | null) => void
diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts
index cd0f6f50ce..f10a9ced20 100644
--- a/packages/client/ui-chat/src/client/locale.ts
+++ b/packages/client/ui-chat/src/client/locale.ts
@@ -31,6 +31,7 @@ export const zh = {
'chat.deepDiving': '深度求索中...',
'chat.turnNavigation.label': '轮次导航',
'chat.turnNavigation.jump': '跳转到第 {turn} 轮',
+ 'chat.turnNavigation.jumpLoad': '加载并跳转到第 {turn} 轮',
'chat.turnNavigation.turn': '第 {turn} 轮',
'settings.transcript.title': '对话显示',
'settings.transcript.description': '控制已完成轮次的过程内容',
@@ -147,6 +148,7 @@ export const en = {
'chat.deepDiving': 'Deep diving...',
'chat.turnNavigation.label': 'Turn navigation',
'chat.turnNavigation.jump': 'Jump to turn {turn}',
+ 'chat.turnNavigation.jumpLoad': 'Load and jump to turn {turn}',
'chat.turnNavigation.turn': 'Turn {turn}',
'settings.transcript.title': 'Conversation display',
'settings.transcript.description': 'Controls process content in completed turns',
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index eccacecc0d..8538f0522e 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -222,6 +222,9 @@ function makeHarness(
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined)
const loadOlder = vi.fn()
+ const loadThrough = vi.fn<(seq: number) => Promise>().mockResolvedValue(undefined)
+ // Mutable outline holder: tests swap the value and drive a re-render via set().
+ let outlineValue: unknown
const openView = vi.fn<(view: string, focus: string) => void>()
// In-memory scroll memory matching the apply.ts per-session map contract.
let savedScroll: ReturnType = null
@@ -348,7 +351,7 @@ function makeHarness(
createSnapshotStore(new Map()),
),
useWorkspaces: emptyWorkspaces(),
- useProjection: (() => undefined),
+ useProjection: () => outlineValue,
useInput: (() => { throw new Error('unused') }),
inputActions: {
setDraft: () => {},
@@ -368,6 +371,7 @@ function makeHarness(
openDetails,
openFile,
loadOlder,
+ loadThrough,
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
chatScroll,
forkAt,
@@ -396,7 +400,8 @@ function makeHarness(
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return {
set, setSession: session.set, setChat: chatSource.set, ChatView, props,
- openDetails, openFile, loadOlder, openView,
+ openDetails, openFile, loadOlder, loadThrough, openView,
+ setOutline: (value: unknown) => { outlineValue = value },
chatScroll, forkAt, setSelection, toolOwners,
setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) },
setNodeRenderer: (renderer: React.ComponentProps['renderSlot']) => {
@@ -597,6 +602,66 @@ describe('ChatView', () => {
expect(secondPosition.style.getPropertyValue('--turn-position')).toBe('50%')
})
+ it('extends the rail with unloaded outline turns, pages on click, and falls back when nothing lands', async () => {
+ const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
+ const h = makeHarness({ nodes: later }, { hasMore: true })
+ h.setOutline({
+ turns: [
+ { turn: 1, seq: 0, prompt: 'first prompt from outline' },
+ { turn: 2, seq: 4, prompt: 'second prompt from outline' },
+ { turn: 3, seq: 8, prompt: 'third prompt' },
+ ],
+ })
+ const view = render( )
+ const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
+ view.getByRole('button', { name: '加载并跳转到第 2 轮' })
+ const third = view.getByRole('button', { name: '跳转到第 3 轮' })
+ expect(third.getAttribute('aria-current')).toBe('true')
+ fireEvent.focus(first)
+ expect(view.getByRole('tooltip').textContent).toContain('first prompt from outline')
+
+ fireEvent.click(first)
+ expect(h.loadThrough).toHaveBeenCalledWith(0)
+ expect(first.getAttribute('aria-busy')).toBe('true')
+
+ // The fake loader never delivers rows: settlement repages once for the
+ // unmoved head, then lands on the nearest rendered turn and un-busies.
+ await act(async () => {})
+ expect(h.loadThrough.mock.calls).toEqual([[0], [0]])
+ expect(first.getAttribute('aria-busy')).toBeNull()
+ expect(view.getByRole('button', { name: '跳转到第 3 轮' }).getAttribute('aria-current')).toBe('true')
+ })
+
+ it('lands a jump on its turn once the paged rows commit', async () => {
+ const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
+ const h = makeHarness({ nodes: later }, { hasMore: true })
+ h.setOutline({
+ turns: [
+ { turn: 1, seq: 0, prompt: 'first prompt' },
+ { turn: 3, seq: 8, prompt: 'third prompt' },
+ ],
+ })
+ let releaseJump: (() => void) | undefined
+ h.loadThrough.mockImplementation(() => new Promise((resolve) => { releaseJump = resolve }))
+ const view = render( )
+
+ fireEvent.click(view.getByRole('button', { name: '加载并跳转到第 1 轮' }))
+ expect(h.loadThrough).toHaveBeenCalledWith(0)
+
+ // The paged window commits: turn 1's rows and rail item enter the snapshot.
+ act(() => {
+ h.setChat({
+ nodes: [userInTurn(0, 'first prompt', 1), assistant(1, 'first response', 1), ...later],
+ turnTimings: new Map([[1, { startTime: 1_000 }], [3, { startTime: 8_000 }]]),
+ })
+ })
+ const first = view.getByRole('button', { name: '跳转到第 1 轮' })
+ expect(first.getAttribute('aria-current')).toBe('true')
+ expect(first.getAttribute('aria-busy')).toBeNull()
+ await act(async () => { releaseJump?.() })
+ expect(first.getAttribute('aria-current')).toBe('true')
+ })
+
it('hands a windowless tool result to the Tool seat with an empty tool name', () => {
const h = makeHarness({
nodes: [{ ...toolResult(3, 'w1'), call: null }],
diff --git a/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts b/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts
new file mode 100644
index 0000000000..f53aeebd72
--- /dev/null
+++ b/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts
@@ -0,0 +1,74 @@
+// @vitest-environment jsdom
+
+import { describe, expect, it } from 'vitest'
+import { mergeTurnRailItems } from '../src/client/chat/turn-rail-items.ts'
+import type { TurnNavigationItem } from '../src/client/contract/snapshot.ts'
+
+function loadedItem(turn: number, prompt = `p${String(turn)}`, response = `r${String(turn)}`): TurnNavigationItem {
+ return { turn, anchorKey: `anchor-${String(turn)}`, prompt, response }
+}
+
+describe('mergeTurnRailItems', () => {
+ it('returns a stable empty array when both sides are empty', () => {
+ expect(mergeTurnRailItems([], undefined)).toBe(mergeTurnRailItems([], { turns: [] }))
+ })
+
+ it('maps outline-only turns to unloaded marks in ascending order', () => {
+ const items = mergeTurnRailItems([], {
+ turns: [
+ { turn: 1, seq: 0, prompt: 'first' },
+ { turn: 2, seq: 9, prompt: '' },
+ ],
+ })
+ expect(items).toEqual([
+ { turn: 1, prompt: 'first', response: '', anchor: { kind: 'unloaded', seq: 0 } },
+ { turn: 2, prompt: '', response: '', anchor: { kind: 'unloaded', seq: 9 } },
+ ])
+ })
+
+ it('prefers the loaded side on overlap but falls back to the outline prompt for a mid-Turn window head', () => {
+ const items = mergeTurnRailItems(
+ [loadedItem(2, '', 'answer two'), loadedItem(3)],
+ {
+ turns: [
+ { turn: 1, seq: 0, prompt: 'one' },
+ { turn: 2, seq: 8, prompt: 'two from outline' },
+ { turn: 3, seq: 16, prompt: 'three from outline' },
+ ],
+ },
+ )
+ expect(items).toEqual([
+ { turn: 1, prompt: 'one', response: '', anchor: { kind: 'unloaded', seq: 0 } },
+ { turn: 2, prompt: 'two from outline', response: 'answer two', anchor: { kind: 'loaded', key: 'anchor-2' } },
+ { turn: 3, prompt: 'p3', response: 'r3', anchor: { kind: 'loaded', key: 'anchor-3' } },
+ ])
+ })
+
+ it('passes loaded turns through when the outline is absent or lagging', () => {
+ expect(mergeTurnRailItems([loadedItem(7)], undefined)).toEqual([
+ { turn: 7, prompt: 'p7', response: 'r7', anchor: { kind: 'loaded', key: 'anchor-7' } },
+ ])
+ expect(mergeTurnRailItems([loadedItem(4)], { turns: [{ turn: 3, seq: 1, prompt: 'older' }] })).toEqual([
+ { turn: 3, prompt: 'older', response: '', anchor: { kind: 'unloaded', seq: 1 } },
+ { turn: 4, prompt: 'p4', response: 'r4', anchor: { kind: 'loaded', key: 'anchor-4' } },
+ ])
+ })
+
+ it('drops malformed wire entries and shapes without folding the rail', () => {
+ expect(mergeTurnRailItems([loadedItem(1)], 'not an outline')).toEqual([
+ { turn: 1, prompt: 'p1', response: 'r1', anchor: { kind: 'loaded', key: 'anchor-1' } },
+ ])
+ const items = mergeTurnRailItems([], {
+ turns: [
+ { turn: -1, seq: 0, prompt: 'negative turn' },
+ { turn: 2, seq: 0.5, prompt: 'fractional seq' },
+ { turn: 3, seq: 4, prompt: 5 },
+ { turn: 6, seq: 7, prompt: 'kept' },
+ null,
+ ],
+ })
+ expect(items).toEqual([
+ { turn: 6, prompt: 'kept', response: '', anchor: { kind: 'unloaded', seq: 7 } },
+ ])
+ })
+})
diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json
index 4d42320885..abe62ed5f0 100644
--- a/packages/client/ui-chat/tsconfig.json
+++ b/packages/client/ui-chat/tsconfig.json
@@ -56,6 +56,9 @@
{
"path": "../../session/session-stats"
},
+ {
+ "path": "../../session/session-turn-outline"
+ },
{
"path": "../../settings/settings"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 022f00b2f2..eb52c630ac 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2131,6 +2131,9 @@ importers:
'@deepseek-ai/dsh-session-stats':
specifier: workspace:^
version: link:../../session/session-stats
+ '@deepseek-ai/dsh-session-turn-outline':
+ specifier: workspace:^
+ version: link:../../session/session-turn-outline
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
From 6af1ee49b111d44df630eaa2a1a53fb1746c1a4d Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 13:11:31 +0800
Subject: [PATCH 07/26] feat(ui-chat): scrollable fixed-pitch turn rail
Marks keep a fixed 10px pitch instead of compressing into the frame:
overflow scrolls inside a hidden-scrollbar scroller with gradient fades
over each still-scrollable end, the preview compensates the rail scroll,
and the active mark keeps itself centred while the pointer is off the
rail. Pointer-to-mark mapping now works in ladder coordinates.
---
.../src/client/chat/TurnNavigator.module.css | 59 ++++--
.../ui-chat/src/client/chat/TurnNavigator.tsx | 195 +++++++++++++-----
.../ui-chat/tests/chat-view.client.spec.tsx | 41 +++-
3 files changed, 226 insertions(+), 69 deletions(-)
diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css
index af13b07ba0..c821c2393b 100644
--- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css
+++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css
@@ -8,7 +8,7 @@
pointer-events: none;
}
-.rail {
+.frame {
/* The band a reader actually sees: the scrollport minus the sticky composer
stack covering its floor. ConversationRoot publishes both measurements on
the scrollport; the fallbacks carry the first paint before its observer
@@ -24,9 +24,9 @@
padding, so the rail gives that inset back and keeps 12px of its own. */
right: calc(12px - (var(--dsh-composer-side-clearance) + 16px));
width: 28px;
- /* Never taller than the band it centers in: a short window (a tall composer,
- a low viewport) shrinks the rail instead of pushing marks under the
- composer or above the scrollport. */
+ /* Fixed-pitch marks never compress: a ladder taller than the band scrolls
+ inside this frame instead of pushing marks under the composer or above
+ the scrollport. */
height: min(
var(--turn-natural-height),
max(0px, calc(var(--turn-rail-band) - 64px)),
@@ -38,14 +38,46 @@
transition: height 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
-.marks {
+/* The frame's inner scroller: no visible scrollbar, no scroll chaining into
+ the transcript, and gradient fades over the ends that can still scroll. */
+.scroller {
position: absolute;
- inset: var(--turn-rail-inset) 0;
+ inset: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-width: none;
+}
+
+.scroller::-webkit-scrollbar {
+ display: none;
+}
+
+.fadeTop {
+ mask-image: linear-gradient(to bottom, transparent 0, #000 24px, #000 100%);
+}
+
+.fadeBottom {
+ mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 24px), transparent 100%);
+}
+
+.fadeTop.fadeBottom {
+ mask-image: linear-gradient(
+ to bottom,
+ transparent 0,
+ #000 24px,
+ #000 calc(100% - 24px),
+ transparent 100%
+ );
+}
+
+.marks {
+ position: relative;
+ height: var(--turn-natural-height);
}
.markPosition {
position: absolute;
- top: min(var(--turn-natural-position), var(--turn-position));
+ top: calc(var(--turn-natural-position) + var(--turn-rail-inset));
right: 0;
left: 0;
height: 10px;
@@ -118,13 +150,14 @@
.preview {
position: absolute;
- /* Centered on its mark (mark positions are measured inside the rail inset),
- then held clear of both rail ends. */
+ /* Centered on its mark. Mark positions live in the scrolled ladder, so the
+ frame-level preview subtracts the scroller's offset, then holds clear of
+ both frame ends. */
top: clamp(
0px,
calc(
- min(var(--turn-natural-position), var(--turn-position))
- + var(--turn-rail-inset) - var(--turn-preview-height) / 2
+ var(--turn-natural-position) + var(--turn-rail-inset)
+ - var(--turn-scroll-top, 0px) - var(--turn-preview-height) / 2
),
calc(100% - var(--turn-preview-height))
);
@@ -186,12 +219,14 @@
}
@media (prefers-reduced-motion: reduce) {
- .rail,
+ .frame,
+ .scroller,
.markPosition,
.mark::before,
.markBusy::before,
.preview {
transition: none;
animation: none;
+ scroll-behavior: auto;
}
}
diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx
index 3eef55269a..f56b3ab3f3 100644
--- a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx
+++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx
@@ -1,5 +1,6 @@
import {
- memo, useId, useState, type CSSProperties, type MouseEvent, type PointerEvent,
+ memo, useEffect, useId, useRef, useState,
+ type CSSProperties, type MouseEvent, type PointerEvent,
} from 'react'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { TurnRailItem } from './turn-rail-items.ts'
@@ -14,102 +15,184 @@ interface TurnNavigatorProps {
readonly t: ChatViewSlotProps['t']
}
-/** Resting gap between neighbouring marks before the rail compresses to fit. */
+/** Fixed pitch between neighbouring marks; overflow scrolls inside the frame. */
const TURN_SPACING_PX = 10
/** Rail padding above the first mark and below the last one, per end. */
const RAIL_INSET_PX = 6
+/** Fade band the mask reserves at a scrollable end. */
+const FADE_PX = 24
type TurnPositionStyle = CSSProperties & {
readonly '--turn-natural-position': string
- readonly '--turn-position': string
}
-type TurnRailStyle = CSSProperties & {
+type TurnFrameStyle = CSSProperties & {
readonly '--turn-natural-height': string
readonly '--turn-rail-inset': string
+ readonly '--turn-scroll-top': string
}
-function itemPosition(index: number, count: number): TurnPositionStyle {
- const ratio = count <= 1 ? 0 : index / (count - 1)
- return {
- '--turn-natural-position': `${String(index * TURN_SPACING_PX)}px`,
- '--turn-position': `${String(ratio * 100)}%`,
- }
+function itemPosition(index: number): TurnPositionStyle {
+ return { '--turn-natural-position': `${String(index * TURN_SPACING_PX)}px` }
}
-function railSize(count: number): TurnRailStyle {
+function frameStyle(count: number, scrollTop: number): TurnFrameStyle {
return {
'--turn-natural-height': `${String((count - 1) * TURN_SPACING_PX + 2 * RAIL_INSET_PX)}px`,
'--turn-rail-inset': `${String(RAIL_INSET_PX)}px`,
+ '--turn-scroll-top': `${String(scrollTop)}px`,
}
}
function itemAtPointer(
items: readonly TurnRailItem[],
- rail: HTMLElement,
+ frame: HTMLElement,
+ scrollTop: number,
clientY: number,
): TurnRailItem | undefined {
- const rect = rail.getBoundingClientRect()
- const usableHeight = Math.max(1, rect.height - 2 * RAIL_INSET_PX)
- const ratio = Math.max(0, Math.min(1, (clientY - rect.top - RAIL_INSET_PX) / usableHeight))
- return items[Math.round(ratio * (items.length - 1))]
+ const rect = frame.getBoundingClientRect()
+ const offset = clientY - rect.top + scrollTop - RAIL_INSET_PX
+ const index = Math.max(0, Math.min(items.length - 1, Math.round(offset / TURN_SPACING_PX)))
+ return items[index]
+}
+
+/** Scroll state the mask fades and follow logic read together. */
+interface RailScrollState {
+ readonly top: number
+ readonly canScrollUp: boolean
+ readonly canScrollDown: boolean
+}
+
+const RAIL_AT_REST: RailScrollState = { top: 0, canScrollUp: false, canScrollDown: false }
+
+function railScrollState(scroller: HTMLElement): RailScrollState {
+ const top = scroller.scrollTop
+ return {
+ top,
+ canScrollUp: top > 1,
+ canScrollDown: top < scroller.scrollHeight - scroller.clientHeight - 1,
+ }
+}
+
+function sameRailScrollState(left: RailScrollState, right: RailScrollState): boolean {
+ return left.top === right.top
+ && left.canScrollUp === right.canScrollUp
+ && left.canScrollDown === right.canScrollDown
}
function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, t }: TurnNavigatorProps) {
const [previewTurn, setPreviewTurn] = useState(null)
+ const [scrollState, setScrollState] = useState(RAIL_AT_REST)
+ const scrollerRef = useRef(null)
+ /** While the pointer works the rail, follow must not move it under the hand. */
+ const pointerInsideRef = useRef(false)
const previewId = useId()
+
+ const syncScrollState = (): void => {
+ const scroller = scrollerRef.current
+ if (scroller === null) return
+ const next = railScrollState(scroller)
+ setScrollState(current => sameRailScrollState(current, next) ? current : next)
+ }
+
+ // Frame resizes (band/composer changes) move the overflow edges without a
+ // scroll event; item count changes move the content height the same way.
+ useEffect(() => {
+ const scroller = scrollerRef.current
+ if (scroller === null || typeof ResizeObserver === 'undefined') return
+ const observer = new ResizeObserver(syncScrollState)
+ observer.observe(scroller)
+ return () => { observer.disconnect() }
+ }, [])
+ useEffect(syncScrollState, [items.length])
+
+ // Keep the active mark visible: centre it whenever it leaves the scrollport,
+ // unless the reader's pointer is working the rail.
+ useEffect(() => {
+ const scroller = scrollerRef.current
+ const index = items.findIndex(item => item.turn === activeTurn)
+ if (scroller === null || index < 0 || pointerInsideRef.current) return
+ const markTop = index * TURN_SPACING_PX + RAIL_INSET_PX
+ const viewTop = scroller.scrollTop
+ const viewHeight = scroller.clientHeight
+ if (viewHeight <= 0 || (markTop >= viewTop + FADE_PX && markTop <= viewTop + viewHeight - FADE_PX)) return
+ const target = Math.max(0, markTop - viewHeight / 2)
+ const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
+ if (typeof scroller.scrollTo === 'function') {
+ scroller.scrollTo({ top: target, behavior: reduced ? 'auto' : 'smooth' })
+ } else {
+ scroller.scrollTop = target
+ }
+ syncScrollState()
+ }, [activeTurn, items])
+
if (items.length < 2) return null
const previewIndex = items.findIndex(item => item.turn === previewTurn)
const preview = previewIndex < 0 ? undefined : items[previewIndex]
- const previewPosition = previewIndex < 0 ? undefined : itemPosition(previewIndex, items.length)
+ const previewPosition = previewIndex < 0 ? undefined : itemPosition(previewIndex)
const previewAtPointer = (event: PointerEvent): void => {
- setPreviewTurn(itemAtPointer(items, event.currentTarget, event.clientY)?.turn ?? null)
+ const scrollTop = scrollerRef.current?.scrollTop ?? 0
+ setPreviewTurn(itemAtPointer(items, event.currentTarget, scrollTop, event.clientY)?.turn ?? null)
}
const navigateAtPointer = (event: MouseEvent): void => {
- const item = itemAtPointer(items, event.currentTarget, event.clientY)
+ const scrollTop = scrollerRef.current?.scrollTop ?? 0
+ const item = itemAtPointer(items, event.currentTarget, scrollTop, event.clientY)
if (item !== undefined) onNavigate(item)
}
+ const fadeClasses = [css.scroller]
+ if (scrollState.canScrollUp) fadeClasses.push(css.fadeTop)
+ if (scrollState.canScrollDown) fadeClasses.push(css.fadeBottom)
return (
{ setPreviewTurn(null) }}
+ onPointerEnter={() => { pointerInsideRef.current = true }}
+ onPointerLeave={() => {
+ pointerInsideRef.current = false
+ setPreviewTurn(null)
+ }}
>
-
- {items.map((item, index) => {
- const active = item.turn === activeTurn
- const showingPreview = item.turn === previewTurn
- const classes = [css.mark]
- if (item.anchor.kind === 'unloaded') classes.push(css.markUnloaded)
- if (active) classes.push(css.markActive)
- else if (showingPreview) classes.push(css.markPreview)
- if (item.turn === busyTurn) classes.push(css.markBusy)
- return (
-
- {
- event.stopPropagation()
- onNavigate(item)
- }}
- onFocus={() => { setPreviewTurn(item.turn) }}
- onBlur={() => { setPreviewTurn(null) }}
- />
-
- )
- })}
+
{ syncScrollState() }}
+ >
+
+ {items.map((item, index) => {
+ const active = item.turn === activeTurn
+ const showingPreview = item.turn === previewTurn
+ const classes = [css.mark]
+ if (item.anchor.kind === 'unloaded') classes.push(css.markUnloaded)
+ if (active) classes.push(css.markActive)
+ else if (showingPreview) classes.push(css.markPreview)
+ if (item.turn === busyTurn) classes.push(css.markBusy)
+ return (
+
+ {
+ event.stopPropagation()
+ onNavigate(item)
+ }}
+ onFocus={() => { setPreviewTurn(item.turn) }}
+ onBlur={() => { setPreviewTurn(null) }}
+ />
+
+ )
+ })}
+
{preview !== undefined && previewPosition !== undefined && (
@@ -125,8 +208,10 @@ function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, t }: TurnN
}
/**
- * Compact rail of every known Turn — loaded marks scroll, unloaded marks page
- * history in first — with hover and focus previews.
+ * Fixed-pitch rail of every known Turn — loaded marks scroll, unloaded marks
+ * page history in first — with hover and focus previews. Overflow scrolls
+ * inside the frame, gradient fades marking each scrollable end, and the
+ * active mark keeps itself in view while the pointer is elsewhere.
*
* Memoized because it renders two host elements per Turn while the
* enclosing view re-renders on every streaming delta: without the guard a long
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 8538f0522e..d47c4519d6 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -578,7 +578,7 @@ describe('ChatView', () => {
const view = render(
)
const second = view.getByRole('button', { name: '跳转到第 2 轮' })
const secondPosition = second.parentElement as HTMLElement
- expect(secondPosition.style.getPropertyValue('--turn-position')).toBe('0%')
+ expect(secondPosition.style.getPropertyValue('--turn-natural-position')).toBe('0px')
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
@@ -598,8 +598,8 @@ describe('ChatView', () => {
})
const movedSecond = view.getByRole('button', { name: '跳转到第 2 轮' })
expect(movedSecond.parentElement).toBe(secondPosition)
+ // Fixed pitch: the mark moves one slot down and never compresses.
expect(secondPosition.style.getPropertyValue('--turn-natural-position')).toBe('10px')
- expect(secondPosition.style.getPropertyValue('--turn-position')).toBe('50%')
})
it('extends the rail with unloaded outline turns, pages on click, and falls back when nothing lands', async () => {
@@ -632,6 +632,43 @@ describe('ChatView', () => {
expect(view.getByRole('button', { name: '跳转到第 3 轮' }).getAttribute('aria-current')).toBe('true')
})
+ it('scrolls the fixed-pitch rail inside its frame with gradient fades at the scrollable ends', () => {
+ const h = makeHarness(
+ { nodes: [userInTurn(8, 'latest prompt', 60), assistant(9, 'latest response', 60)] },
+ { hasMore: true },
+ )
+ h.setOutline({
+ turns: Array.from({ length: 60 }, (_, index) => ({
+ turn: index + 1,
+ seq: index * 4,
+ prompt: `p${String(index + 1)}`,
+ })),
+ })
+ const view = render(
)
+ const nav = view.getByRole('navigation', { name: '轮次导航' })
+ // 60 marks at the fixed 10px pitch: the ladder keeps its natural height.
+ expect(nav.style.getPropertyValue('--turn-natural-height')).toBe('602px')
+ const scroller = nav.querySelector('[class*="scroller"]') as HTMLElement
+ Object.defineProperty(scroller, 'scrollHeight', { value: 602, configurable: true })
+ Object.defineProperty(scroller, 'clientHeight', { value: 300, configurable: true })
+ scroller.scrollTop = 0
+ fireEvent.scroll(scroller)
+ expect(scroller.className).toContain('fadeBottom')
+ expect(scroller.className).not.toContain('fadeTop')
+
+ scroller.scrollTop = 150
+ fireEvent.scroll(scroller)
+ expect(scroller.className).toContain('fadeTop')
+ expect(scroller.className).toContain('fadeBottom')
+ expect(nav.style.getPropertyValue('--turn-scroll-top')).toBe('150px')
+
+ // Pointer mapping subtracts the rail scroll: y=94 with scrollTop 150 is
+ // natural offset 238px → the 25th mark.
+ vi.spyOn(nav, 'getBoundingClientRect').mockReturnValue({ top: 0 } as DOMRect)
+ fireEvent.pointerMove(nav, { clientY: 94 })
+ expect(view.getByRole('tooltip').textContent).toContain('p25')
+ })
+
it('lands a jump on its turn once the paged rows commit', async () => {
const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
const h = makeHarness({ nodes: later }, { hasMore: true })
From 3a834fe6c952ce3eb80509cab6a7396164e8a193 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 13:18:03 +0800
Subject: [PATCH 08/26] feat(ui-chat): settle jump landings after paging
completes
A mid-paging landing keeps the jump armed with the target row as its
paging anchor, so later chunks and the load-earlier button's unmount
cannot drift the landing; the loader's completion runs one final
correction unless the reader already scrolled off the target. Adds the
browser contract: full outline ladder, keyboard jump on an unloaded
mark, landing geometry, and rail fades.
---
apps/web/tests/chat-scroll-contract.e2e.ts | 57 +++++++++++++++++++
.../ui-chat/src/client/chat/ChatView.tsx | 40 ++++++++++---
.../ui-chat/tests/chat-view.client.spec.tsx | 5 +-
3 files changed, 93 insertions(+), 9 deletions(-)
diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts
index 2664ef0f01..3058f669c5 100644
--- a/apps/web/tests/chat-scroll-contract.e2e.ts
+++ b/apps/web/tests/chat-scroll-contract.e2e.ts
@@ -42,6 +42,7 @@ const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
const TOOL_READY_FILE = '.chat-scroll-tool-ready'
const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e'
+const RAIL_SESSION_ID = 'chat-scroll-rail-e2e'
const FLING_SESSION_ID = 'chat-scroll-fling-e2e'
const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.'
const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST'
@@ -557,6 +558,62 @@ describe('web e2e: long Chat scroll contract', () => {
})
}, 180_000)
+ it.skipIf(MODE === 'record')('offers every outline turn on the rail and jumps to an unloaded one', async () => {
+ await withScrollWorld({
+ failureShot: 'web-e2e-turn-rail-jump',
+ seeds: [{ fixture: HISTORY_FIXTURE, id: RAIL_SESSION_ID }],
+ }, async (world) => {
+ await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
+ await expectBottom(world.page)
+
+ // The whole-log outline reaches the rail before any paging: one mark
+ // per fixture turn, the oldest still in its load-and-jump form.
+ const rail = world.page.getByRole('navigation', { name: 'Turn navigation' })
+ await expect.poll(() => rail.getByRole('button').count(), { timeout: 15_000 })
+ .toBe(HISTORY_FIXTURE.turns)
+ const firstUnloaded = rail.getByRole('button', { name: 'Load and jump to turn 1', exact: true })
+ expect(await firstUnloaded.count()).toBe(1)
+ // Fixed pitch: the ladder keeps its natural height, scrolls inside the
+ // frame, and (following the active tail mark) fades its upper end.
+ expect(await rail.evaluate(nav => nav.style.getPropertyValue('--turn-natural-height')))
+ .toBe(`${String((HISTORY_FIXTURE.turns - 1) * 10 + 12)}px`)
+ const railScroller = rail.locator('[class*="scroller"]')
+ await expect.poll(() => railScroller.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true)
+ await expect.poll(() => rail.locator('[class*="fadeTop"]').count(), { timeout: 15_000 }).toBe(1)
+
+ // Activate the unloaded mark by keyboard: pointer input belongs to the
+ // rail frame, while each mark is the keyboard/AT destination.
+ const beforeRows = await loadedFlowRows(world.page)
+ await firstUnloaded.focus()
+ await world.page.keyboard.press('Enter')
+
+ // The jump pages history in and lands on turn 1: its mark flips to the
+ // loaded label and becomes current, the window grew, and the turn-1
+ // user row sits at the reading line.
+ const firstLoaded = rail.getByRole('button', { name: 'Jump to turn 1', exact: true })
+ await expect.poll(() => firstLoaded.count(), { timeout: 60_000 }).toBe(1)
+ await expect.poll(() => firstLoaded.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true')
+ expect(await loadedFlowRows(world.page)).toBeGreaterThan(beforeRows)
+ // Drop mark focus so its hover/focus preview (which echoes the prompt
+ // marker) leaves the DOM before the transcript count below.
+ await firstLoaded.evaluate((el) => { (el as HTMLElement).blur() })
+ await expect.poll(() => world.page.getByRole('tooltip').count(), { timeout: 15_000 }).toBe(0)
+ await nextPaint(world.page)
+ const marker = world.page.locator('[data-conversation-scroll]')
+ .getByText(HISTORY_FIXTURE.markers.user(1), { exact: false })
+ expect(await marker.count()).toBe(1)
+ const scrollport = await world.page.locator('[data-conversation-scroll]').boundingBox()
+ const row = await marker.boundingBox()
+ if (scrollport === null || row === null) throw new Error('turn-1 row or scrollport has no layout box')
+ expect(row.y - scrollport.y).toBeGreaterThanOrEqual(0)
+ expect(row.y - scrollport.y).toBeLessThanOrEqual(160)
+ // The rail followed the landing to the ladder top, so the fade now
+ // marks the other (downward) end.
+ await expect.poll(() => rail.locator('[class*="fadeBottom"]').count(), { timeout: 15_000 }).toBe(1)
+ assertClean(world)
+ })
+ }, 180_000)
+
it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-live-tool',
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index 2c41dbd057..71cd1755de 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -305,6 +305,8 @@ export function ChatView({
const anchorRef = useRef(null)
/** Unloaded-turn jump in flight: target turn plus its load-through seq. */
const pendingJumpRef = useRef<{ turn: number; seq: number } | null>(null)
+ /** Whether the in-flight jump already landed mid-paging (settle then only corrects an untouched landing). */
+ const jumpLandedRef = useRef(false)
const [busyJumpTurn, setBusyJumpTurn] = useState(null)
/** Bumped when a loadThrough completion settles, after its last page's commit. */
const [jumpSettleTick, setJumpSettleTick] = useState(0)
@@ -412,18 +414,37 @@ export function ChatView({
else if (position !== null) chatScroll.save(position)
}
- /** Land the pending jump once its Turn has a rendered anchor row; false while it must keep waiting. */
- const realizePendingJump = (local: HTMLElement, el: HTMLElement): boolean => {
+ /**
+ * Land the pending jump once its Turn has a rendered anchor row; false
+ * while it must keep waiting. Mid-jump landings (`settle` false) keep the
+ * jump armed with the target row as the paging anchor, so later chunks and
+ * the load-earlier button's unmount re-land on the same row; the settling
+ * call clears the jump.
+ */
+ const realizePendingJump = (local: HTMLElement, el: HTMLElement, settle: boolean): boolean => {
const pending = pendingJumpRef.current
if (pending === null) return true
const item = railItems.find(candidate => candidate.turn === pending.turn)
if (item === undefined || item.anchor.kind !== 'loaded') return false
const row = anchorElement(local, item.anchor.key)
if (row === null) return false
- pendingJumpRef.current = null
- setBusyJumpTurn(null)
- anchorRef.current = null
+ if (settle) {
+ pendingJumpRef.current = null
+ setBusyJumpTurn(null)
+ const held = anchorRef.current
+ const landedEarlier = jumpLandedRef.current
+ jumpLandedRef.current = false
+ anchorRef.current = null
+ // A reader who moved off an already-landed target mid-jump keeps their
+ // place; a first landing, or an untouched one, takes the correction.
+ if (!landedEarlier || held?.key === item.anchor.key) {
+ landOnRowRef.current(local, el, row, pending.turn)
+ }
+ return true
+ }
landOnRowRef.current(local, el, row, pending.turn)
+ jumpLandedRef.current = true
+ anchorRef.current = { key: item.anchor.key, top: flowTop(row, el) }
return true
}
@@ -470,7 +491,7 @@ export function ChatView({
observedTopRef.current = el.scrollTop
// A jump chunk lands here: scroll to the target once its rows exist;
// until then keep holding the reader's row for the next chunk.
- if (!realizePendingJump(local, el) && row !== null) {
+ if (!realizePendingJump(local, el, false) && row !== null) {
anchorRef.current = { key: anchor.key, top: flowTop(row, el) }
}
firstSeqRef.current = firstSeq
@@ -500,7 +521,7 @@ export function ChatView({
}
// A jump whose target committed outside the anchored-prepend path (for
// example after a mid-jump toBottom dropped the held anchor) lands here.
- if (pendingJumpRef.current !== null) realizePendingJump(local, el)
+ if (pendingJumpRef.current !== null) realizePendingJump(local, el, false)
})
const onScrollRef = useRef(() => {})
@@ -604,7 +625,9 @@ export function ChatView({
const local = listRef.current
if (pending === null || local === null) return
const el = scrollerOf(local)
- if (realizePendingJump(local, el)) return
+ // The settling landing runs after the load-earlier button's unmount
+ // commit, so the target row cannot drift once the jump clears.
+ if (realizePendingJump(local, el, true)) return
const uncovered = firstSeq === null || firstSeq > pending.seq
if (uncovered && hasMore && !loadingOlder && jumpRepageHeadRef.current !== firstSeq) {
jumpRepageHeadRef.current = firstSeq
@@ -656,6 +679,7 @@ export function ChatView({
}
pendingJumpRef.current = { turn: item.turn, seq: item.anchor.seq }
jumpRepageHeadRef.current = null
+ jumpLandedRef.current = false
setBusyJumpTurn(item.turn)
void loadThrough(item.anchor.seq).finally(() => { setJumpSettleTick(tick => tick + 1) })
return
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index d47c4519d6..87231f9724 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -694,8 +694,11 @@ describe('ChatView', () => {
})
const first = view.getByRole('button', { name: '跳转到第 1 轮' })
expect(first.getAttribute('aria-current')).toBe('true')
- expect(first.getAttribute('aria-busy')).toBeNull()
+ // The mark stays busy until the jump settles: the loader's completion
+ // runs the final landing correction after the load-earlier button leaves.
+ expect(first.getAttribute('aria-busy')).toBe('true')
await act(async () => { releaseJump?.() })
+ expect(first.getAttribute('aria-busy')).toBeNull()
expect(first.getAttribute('aria-current')).toBe('true')
})
From 422b60387406c974e1965b9c3d1d0172b20306b3 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 13:21:57 +0800
Subject: [PATCH 09/26] docs: turn outline rail contracts and agent note
READMEs record the rail's outline merge and the loadThrough paging
verb in both languages; the feature Agent Note owns the decision,
alternatives (sparse windows, minSeq wire bound, outline RPC, height
estimation), and coverage map. Regenerates the client/API catalogs the
widened faces feed.
---
...08-30-web-turn-rail-outline-jump.i18n.yaml | 6 ++++
.../2026-08-30-web-turn-rail-outline-jump.md | 33 +++++++++++++++++++
...026-08-30-web-turn-rail-outline-jump.zh.md | 33 +++++++++++++++++++
.../api/session-controller/README.i18n.yaml | 4 +--
packages/api/session-controller/README.md | 2 +-
packages/api/session-controller/README.zh.md | 2 +-
packages/client/ui-chat/README.i18n.yaml | 4 +--
packages/client/ui-chat/README.md | 3 +-
packages/client/ui-chat/README.zh.md | 3 +-
.../src/client/api-catalog.ts | 2 +-
.../src/client/slot-catalog.ts | 12 +++----
11 files changed, 89 insertions(+), 15 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
create mode 100644 .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
new file mode 100644
index 0000000000..996c206469
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
+2026-08-30-web-turn-rail-outline-jump.md: 4984cd1325d3e44a9114b36b3245b98a34c92e99
+2026-08-30-web-turn-rail-outline-jump.zh.md: 8a27d72493c49989d33f0e7df786ff1129e66dec
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
new file mode 100644
index 0000000000..4984cd1325
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
@@ -0,0 +1,33 @@
+# Agent Note: Full-session turn rail with outline projection and jump paging
+
+Status: implemented
+
+English | [中文](2026-08-30-web-turn-rail-outline-jump.zh.md)
+
+## Problem
+
+The web chat's turn rail derived its marks from the loaded event window, and the window is a paged suffix of the log (50-message tail, `Load earlier` per page). In a long session the rail therefore named only the most recent turns: history that had not been paged in was invisible to navigation, unreachable except by clicking `Load earlier` repeatedly, and the rail squeezed whatever it did show into a fixed frame by compressing mark spacing to percentages, so a many-turn session degenerated into an unreadable dense strip.
+
+## Decision
+
+Three cooperating pieces, each useful alone.
+
+**Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends `{turn, seq, prompt: ''}` (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), and the turn's first human `user/message` fills a 160-character prompt preview mirroring the rail's loaded-turn preview semantics. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn.
+
+**Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
+
+**View: merge, jump, and the fixed-pitch rail.** `mergeTurnRailItems` (ui-chat, view layer — the conversation snapshot still never carries projection values) unions the outline with the loaded rail items into `TurnRailItem`s discriminated by `anchor: loaded(key) | unloaded(seq)`; loaded wins per turn, and the outline prompt fills a mid-turn window head's empty preview. Activating an unloaded mark holds the reader's place with the existing paging anchor, calls `loadThrough`, and lands after React commits — no height estimation: a mid-paging landing pins the target row as the paging anchor so later chunks and the `Load earlier` button's unmount cannot drift it, and the loader's completion runs one final correction unless the reader already scrolled off the target (settlement otherwise repages once per head movement, then falls back to the nearest rendered turn). The rail itself keeps a fixed 10px pitch: the ladder scrolls inside the old frame geometry behind a hidden scrollbar, gradient fades mark each still-scrollable end, the hover preview compensates the rail scroll, and the active mark keeps itself centred while the pointer is off the rail. Unloaded marks render short and dimmed with a `Load and jump to turn N` label and pulse while their jump pages.
+
+## Alternatives considered
+
+**Sparse or segmented windows** (load only the target turn's neighbourhood): rejected — window contiguity is the foundation the transport validation, assembler, timeline, and scroll anchoring all share; a discontiguous window is a different architecture, deferred until sessions outgrow full paging.
+
+**A `minSeq` page bound on the wire** (one targeted request instead of the client loop): rejected for v1 — the loop needs no protocol change, yields natural per-chunk progress, and Codex's TUI uses the same recursive page-pull shape for its jump-to-start; the single unbounded frame can be revisited if round trips ever dominate.
+
+**A dedicated outline RPC**: rejected — the projection seam already provides the consistency cut with the tail page, live push, persistence, and capability-absence fallback that a bespoke endpoint would have to rebuild.
+
+**Estimated row heights for jump positioning**: rejected — transcript rows vary wildly (tool cards, images, code), so estimation jitters; landing after commit is exact and matches Codex's deferred `pending_scroll_chunk` realization.
+
+## Consequences
+
+The rail is now session-scoped rather than window-scoped, at the cost of a whole-value projection that grows with the session (~200 bytes per turn, pushed at most twice per turn); splitting previews into an on-demand read is deferred until multi-thousand-turn sessions need it. A deep jump still loads every intervening page — the contiguous-window contract — so jumping to turn 1 of a huge session materializes the whole transcript, as manual paging always did. Assemblies without the projection plugin keep the old loaded-only rail. Coverage: projection unit + Loader-composition + HMR specs in the new package, `loadThrough` loop specs in session-controller, merge and jump specs in ui-chat (including the settle correction and busy lifecycle), and a browser contract in the chat-scroll e2e that drives a keyboard jump from an 88-turn fixture's tail to its unloaded first turn and asserts the landing geometry and rail fades.
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
new file mode 100644
index 0000000000..8a27d72493
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 基于大纲投影与跳转分页的整会话轮次导航栏
+
+Status: implemented
+
+[English](2026-08-30-web-turn-rail-outline-jump.md) | 中文
+
+## Problem
+
+Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口是日志的分页后缀(50 条 message 的尾页,每次 `加载更早` 一页)。长会话里导航栏因此只列出最近的轮次:尚未分页载入的历史对导航不可见,除了反复点 `加载更早` 无法到达,且导航栏把已显示的刻度按百分比压缩进固定外框,多轮会话退化成不可读的密集条带。
+
+## Decision
+
+三个相互配合、各自独立可用的部分。
+
+**数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加 `{turn, seq, prompt: ''}`(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入 160 字符的提示词预览,语义与导航栏已加载轮次的预览一致。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。
+
+**分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
+
+**视图:合并、跳转与固定间距导航栏。** `mergeTurnRailItems`(ui-chat,视图层——会话快照仍不携带投影值)把大纲与已加载条目并成以 `anchor: loaded(key) | unloaded(seq)` 判别的 `TurnRailItem`;同轮已加载者优先,大纲提示词填补窗口头部半轮的空预览。激活未加载刻度先用现有分页锚点稳住读者位置、调用 `loadThrough`、在 React 提交后落点——不做任何高度估算:分页中途的落点把目标行钉为分页锚点,后续分片与 `加载更早` 按钮的卸载都不会使落点漂移,加载器完结时再做一次最终校正,除非读者已主动滚离目标(settlement 否则按窗口头每前进一次重发一次分页,再兜底落到最近的已渲染轮次)。导航栏本身保持固定 10px 间距:阶梯在原外框几何内隐藏滚动条滚动,渐变淡出标示仍可滚动的端点,悬浮预览补偿导航栏滚动量,指针不在栏上时活跃刻度自动保持居中。未加载刻度以短而暗的形态呈现,标签为「加载并跳转到第 N 轮」,其跳转分页期间脉冲闪烁。
+
+## Alternatives considered
+
+**稀疏/分段窗口**(只加载目标轮附近):拒绝——窗口连续性是 transport 校验、assembler、timeline 与滚动锚定共同依赖的地基;不连续窗口是另一套架构,推迟到会话规模超出全量分页时再议。
+
+**wire 上加 `minSeq` 页边界**(单发定向请求替代客户端循环):v1 拒绝——循环零协议改动、自带逐片进度,Codex TUI 的跳到开头也是同款递归拉页形态;若往返耗时成为瓶颈再重启无界单帧方案。
+
+**专用大纲 RPC**:拒绝——投影 seam 已自带与尾页的一致性切面、实时推送、持久化与能力缺席回退,专用端点得重造这一切。
+
+**估算行高定位跳转**:拒绝——transcript 行高方差极大(工具卡、图片、代码),估算必抖;提交后落点零误差,且与 Codex 延迟兑现的 `pending_scroll_chunk` 同构。
+
+## Consequences
+
+导航栏从窗口口径变为会话口径,代价是随会话增长的整值投影(约每轮 200 字节,每轮至多推送两次);把预览拆成按需读取推迟到数千轮量级的会话真正需要时。深跳仍会加载沿途所有页——连续窗口契约——跳到超长会话的第 1 轮会实体化整个 transcript,与手动翻页的终态相同。未挂载该投影插件的装配保留旧的仅已加载导航。覆盖:新包的投影单元 + Loader 组合 + HMR 测试、session-controller 的 loadThrough 循环测试、ui-chat 的合并与跳转测试(含 settle 校正与忙碌生命周期),以及 chat-scroll e2e 里的浏览器契约——在 88 轮 fixture 的尾部用键盘跳到未加载的第 1 轮,断言落点几何与导航栏渐变。
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 92d25d2cfd..b4f96c61d2 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: 2f2192cde2941019c9687738f8e3f5e50ddd6d8e
-README.zh.md: 273d304d3cd3bbcbadd3e40202d8d923f3a27498
+README.md: 94e8697cd7f5bf75746edd5d691956e680d9b261
+README.zh.md: 0228ca15624413fcd5cb6e17afb8aaf0a28f780a
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 2f2192cde2..94e8697cd7 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -27,7 +27,7 @@ History pages and follow opening snapshots carry a discriminated `SessionHistory
Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
-The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
+The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index 273d304d3c..0228ca1562 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -27,7 +27,7 @@ kind: "package-reference"
每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
-Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
+Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index d7a5f88cac..293c0fcc41 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 1b24e6bb6825826a97a22a32f43d0210a2f4c6ad
-README.zh.md: 86596bc73ffd1b41867af6559739ab5426adb7b1
+README.md: 0df0ef77b81fcaa16a197633be04d6e6c0d09b71
+README.zh.md: b35443c6207776a18cd85602ffb968a539babbee
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index 1b24e6bb68..0df0ef77b8 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -63,7 +63,8 @@ None; Chat presentation does not assemble or mutate provider requests.
-- **The view reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. Turn navigation likewise represents only loaded Turns; loading an earlier page preserves existing Turn marks and redistributes the complete loaded set in a compact rail without an unloaded-history placeholder. Marks stay 10px apart until the loaded set exceeds the available height, then compress to fit.
+- **The transcript reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. Turn navigation is wider than the window: the rail merges the loaded Turns with the host `turnOutline` projection, so every started Turn gets a fixed-pitch mark (10px apart; a ladder taller than the frame scrolls inside it with gradient fades), and activating an unloaded mark pages history through the Turn's `turn/start` seq before landing on its row. Without the projection (assemblies not mounting `dsh-session-turn-outline`) the rail falls back to loaded Turns only.
+- **Unloaded marks preview the prompt only** — the outline carries no response text, so an unloaded Turn's hover preview shows its first prompt (or just the Turn number) until its events load.
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index 86596bc73f..b35443c620 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -63,7 +63,8 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。读者
-- **视图只反映已加载的 Session 窗口**——只有 Session Controller 加载前一页 event 后,更早的 transcript node 才会出现。轮次导航同样只表示已加载的 Turn;加载更早一页时,已有 Turn 刻度保持身份不变,完整的已加载集合在紧凑轨道中重新排布,不显示未加载历史占位。刻度默认相隔 10px,仅在已加载集合超过可用高度时压缩间距。
+- **transcript 只反映已加载的 Session 窗口**——只有 Session Controller 加载前一页 event 后,更早的 transcript node 才会出现。轮次导航比窗口更宽:轨道把已加载的 Turn 与宿主 `turnOutline` 投影合并,每个已开始的 Turn 都有固定间距刻度(相隔 10px;阶梯高于外框时在框内滚动并以渐变淡出标示可滚方向),激活未加载刻度会先把历史分页拉到该 Turn 的 `turn/start` seq 再落到它的行上。没有该投影时(未挂载 `dsh-session-turn-outline` 的装配),轨道回退到仅显示已加载 Turn。
+- **未加载刻度只预览提示词**——大纲不含回复文本,未加载 Turn 的悬浮预览在其事件载入前只显示首条提示词(或仅轮次号)。
diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
index e10e956a12..55c4f977ef 100644
--- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
@@ -559,7 +559,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ISession',
- declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n command(line: string): Promise>;\n}',
+ declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: number): Promise;\n command(line: string): Promise>;\n}',
},
{
name: 'KeyPropsOf',
diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
index 7fa6e4a9b4..81890bfd98 100644
--- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
@@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:202',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:204',
},
{
key: 'conversation.chat.commandview',
@@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:190',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:192',
},
{
key: 'conversation.chat.node',
@@ -313,7 +313,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:171',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:173',
},
{
key: 'conversation.chat.turnTail',
@@ -358,7 +358,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:196',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:198',
},
{
key: 'conversation.composer',
@@ -537,7 +537,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:208',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:210',
},
{
key: 'conversation.hero.agentPreset',
@@ -1025,7 +1025,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:184',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:186',
},
{
key: 'conversation.session',
From 62f707bb1d131ac0ca1a85c5cec501824ddcbc56 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 13:47:00 +0800
Subject: [PATCH 10/26] fix(ui-chat): release bottom ownership when a jump
starts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Clicking an unloaded rail mark from the pinned tail raced the pinned
scroll snap: the first prepend's compensation fires a non-reader scroll
delivery, the snap called toBottom, and toBottom cancels a pending jump
— so the jump silently stayed at the tail while history loaded. The
click now drops atBottom itself (jumping into history is leaving the
live tail), pinned by a jsdom regression and re-verified live: a
118-turn session lands on turn 1 in ~250ms from click.
---
...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +--
.../2026-08-30-web-turn-rail-outline-jump.md | 2 +-
...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +-
.../ui-chat/src/client/chat/ChatView.tsx | 6 +++++
.../ui-chat/tests/chat-view.client.spec.tsx | 27 +++++++++++++++++++
5 files changed, 37 insertions(+), 4 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
index 996c206469..d78900be1b 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
-2026-08-30-web-turn-rail-outline-jump.md: 4984cd1325d3e44a9114b36b3245b98a34c92e99
-2026-08-30-web-turn-rail-outline-jump.zh.md: 8a27d72493c49989d33f0e7df786ff1129e66dec
+2026-08-30-web-turn-rail-outline-jump.md: eb4e7f09abd66135d0aef1175729e9493b9b6db2
+2026-08-30-web-turn-rail-outline-jump.zh.md: 6851456667d0b9f6d565b941189385b7392a5503
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
index 4984cd1325..eb4e7f09ab 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
@@ -16,7 +16,7 @@ Three cooperating pieces, each useful alone.
**Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
-**View: merge, jump, and the fixed-pitch rail.** `mergeTurnRailItems` (ui-chat, view layer — the conversation snapshot still never carries projection values) unions the outline with the loaded rail items into `TurnRailItem`s discriminated by `anchor: loaded(key) | unloaded(seq)`; loaded wins per turn, and the outline prompt fills a mid-turn window head's empty preview. Activating an unloaded mark holds the reader's place with the existing paging anchor, calls `loadThrough`, and lands after React commits — no height estimation: a mid-paging landing pins the target row as the paging anchor so later chunks and the `Load earlier` button's unmount cannot drift it, and the loader's completion runs one final correction unless the reader already scrolled off the target (settlement otherwise repages once per head movement, then falls back to the nearest rendered turn). The rail itself keeps a fixed 10px pitch: the ladder scrolls inside the old frame geometry behind a hidden scrollbar, gradient fades mark each still-scrollable end, the hover preview compensates the rail scroll, and the active mark keeps itself centred while the pointer is off the rail. Unloaded marks render short and dimmed with a `Load and jump to turn N` label and pulse while their jump pages.
+**View: merge, jump, and the fixed-pitch rail.** `mergeTurnRailItems` (ui-chat, view layer — the conversation snapshot still never carries projection values) unions the outline with the loaded rail items into `TurnRailItem`s discriminated by `anchor: loaded(key) | unloaded(seq)`; loaded wins per turn, and the outline prompt fills a mid-turn window head's empty preview. Activating an unloaded mark releases bottom ownership on the click itself (jumping into history is leaving the live tail; otherwise the pinned-scroll snap racing the first prepend's compensation would call `toBottom` and cancel the jump), holds the reader's place with the existing paging anchor, calls `loadThrough`, and lands after React commits — no height estimation: a mid-paging landing pins the target row as the paging anchor so later chunks and the `Load earlier` button's unmount cannot drift it, and the loader's completion runs one final correction unless the reader already scrolled off the target (settlement otherwise repages once per head movement, then falls back to the nearest rendered turn). The rail itself keeps a fixed 10px pitch: the ladder scrolls inside the old frame geometry behind a hidden scrollbar, gradient fades mark each still-scrollable end, the hover preview compensates the rail scroll, and the active mark keeps itself centred while the pointer is off the rail. Unloaded marks render short and dimmed with a `Load and jump to turn N` label and pulse while their jump pages.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
index 8a27d72493..6851456667 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
@@ -16,7 +16,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口
**分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
-**视图:合并、跳转与固定间距导航栏。** `mergeTurnRailItems`(ui-chat,视图层——会话快照仍不携带投影值)把大纲与已加载条目并成以 `anchor: loaded(key) | unloaded(seq)` 判别的 `TurnRailItem`;同轮已加载者优先,大纲提示词填补窗口头部半轮的空预览。激活未加载刻度先用现有分页锚点稳住读者位置、调用 `loadThrough`、在 React 提交后落点——不做任何高度估算:分页中途的落点把目标行钉为分页锚点,后续分片与 `加载更早` 按钮的卸载都不会使落点漂移,加载器完结时再做一次最终校正,除非读者已主动滚离目标(settlement 否则按窗口头每前进一次重发一次分页,再兜底落到最近的已渲染轮次)。导航栏本身保持固定 10px 间距:阶梯在原外框几何内隐藏滚动条滚动,渐变淡出标示仍可滚动的端点,悬浮预览补偿导航栏滚动量,指针不在栏上时活跃刻度自动保持居中。未加载刻度以短而暗的形态呈现,标签为「加载并跳转到第 N 轮」,其跳转分页期间脉冲闪烁。
+**视图:合并、跳转与固定间距导航栏。** `mergeTurnRailItems`(ui-chat,视图层——会话快照仍不携带投影值)把大纲与已加载条目并成以 `anchor: loaded(key) | unloaded(seq)` 判别的 `TurnRailItem`;同轮已加载者优先,大纲提示词填补窗口头部半轮的空预览。激活未加载刻度在点击当下即交出钉底所有权(跳进历史就是离开活跃尾部;否则钉底吸附与首个 prepend 补偿的竞态会触发 `toBottom` 取消跳转),再用现有分页锚点稳住读者位置、调用 `loadThrough`、在 React 提交后落点——不做任何高度估算:分页中途的落点把目标行钉为分页锚点,后续分片与 `加载更早` 按钮的卸载都不会使落点漂移,加载器完结时再做一次最终校正,除非读者已主动滚离目标(settlement 否则按窗口头每前进一次重发一次分页,再兜底落到最近的已渲染轮次)。导航栏本身保持固定 10px 间距:阶梯在原外框几何内隐藏滚动条滚动,渐变淡出标示仍可滚动的端点,悬浮预览补偿导航栏滚动量,指针不在栏上时活跃刻度自动保持居中。未加载刻度以短而暗的形态呈现,标签为「加载并跳转到第 N 轮」,其跳转分页期间脉冲闪烁。
## Alternatives considered
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index 71cd1755de..0b422d0281 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -671,6 +671,12 @@ export function ChatView({
if (local === null) return
const el = scrollerOf(local)
if (item.anchor.kind === 'unloaded') {
+ // Jumping into history is leaving the live tail: release bottom
+ // ownership on the click itself, or the pinned-scroll snap (a
+ // non-reader scroll delivery during the first prepend's compensation)
+ // would call toBottom and cancel the jump.
+ atBottomRef.current = false
+ setAtBottom(false)
// Hold the reader's place through the paging chunks; the layout effect
// lands on the target once its rows commit.
const held = pagingAnchor(local, el)
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 87231f9724..6be1416036 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -632,6 +632,33 @@ describe('ChatView', () => {
expect(view.getByRole('button', { name: '跳转到第 3 轮' }).getAttribute('aria-current')).toBe('true')
})
+ it('a jump from the pinned tail releases bottom ownership so the follow snap cannot cancel it', async () => {
+ const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
+ const h = makeHarness({ nodes: later }, { hasMore: true })
+ h.setOutline({
+ turns: [
+ { turn: 1, seq: 0, prompt: 'first prompt' },
+ { turn: 3, seq: 8, prompt: 'third prompt' },
+ ],
+ })
+ let releaseJump: (() => void) | undefined
+ h.loadThrough.mockImplementation(() => new Promise((resolve) => { releaseJump = resolve }))
+ const view = render( )
+ // Pinned to the tail on open: the back-to-bottom control is absent.
+ expect(view.queryByRole('button', { name: '回到底部' })).toBeNull()
+
+ const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
+ fireEvent.click(first)
+ // The click itself leaves the tail...
+ expect(view.getByRole('button', { name: '回到底部' })).toBeTruthy()
+ // ...so a non-reader scroll delivery at the floor (the first prepend's
+ // compensation fires one) no longer snaps to the tail and cancel the jump.
+ const scroller = view.container.querySelector('[class*="scroll"]') as HTMLElement
+ fireEvent.scroll(scroller)
+ expect(first.getAttribute('aria-busy')).toBe('true')
+ await act(async () => { releaseJump?.() })
+ })
+
it('scrolls the fixed-pitch rail inside its frame with gradient fades at the scrollable ends', () => {
const h = makeHarness(
{ nodes: [userInTurn(8, 'latest prompt', 60), assistant(9, 'latest response', 60)] },
From ceadd90e7112dcb5b8ae4d8c99847c79eb205333 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 14:28:41 +0800
Subject: [PATCH 11/26] feat(session-projection): identity-gated change feed
The feed previously fired on every changed state reference of a
client-visible unit; it now also compares the raw view output against
the last delivered one and stays quiet when Object.is-identical, so a
unit can buffer working fields in state behind an identity-stable
projection. Units whose views build fresh objects per call are
unaffected.
---
docs/subsystems/session-projection.i18n.yaml | 4 +--
docs/subsystems/session-projection.md | 13 ++++---
docs/subsystems/session-projection.zh.md | 13 ++++---
.../extensions/tool-cordis/src/api-catalog.ts | 2 +-
.../session-projection/README.i18n.yaml | 4 +--
packages/session/session-projection/README.md | 2 +-
.../session/session-projection/README.zh.md | 2 +-
.../session/session-projection/src/index.ts | 27 +++++++++++---
.../session-projection/tests/registry.spec.ts | 36 +++++++++++++++++++
9 files changed, 81 insertions(+), 22 deletions(-)
diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml
index 03ac31e8c1..77c585ceca 100644
--- a/docs/subsystems/session-projection.i18n.yaml
+++ b/docs/subsystems/session-projection.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: 8d40a54762d6f9f90a594ede5951f17747be30af
-session-projection.zh.md: 763798cb8d745fc7b2838f9a2e769973613c824c
+session-projection.md: ee6b301bde49986fb24e75a8de7fec7b7fc41688
+session-projection.zh.md: 36b6c874b440165ec56dddc938aff030fcc69b49
diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md
index 8d40a54762..ee6b301bde 100644
--- a/docs/subsystems/session-projection.md
+++ b/docs/subsystems/session-projection.md
@@ -83,9 +83,12 @@ interface ProjectionSnapshot {
```ts type-equiv
/**
- * Change-feed listener: one unit's value changed for one session. `value` is
- * the schema-validated `view` output; `seq` is the unit's watermark at
- * emission (the seq of the event that caused the change).
+ * Change-feed listener: one unit's served value changed for one session.
+ * `value` is the schema-validated `view` output; `seq` is the unit's
+ * watermark at emission (the seq of the event that caused the change). A
+ * changed state whose raw `view` output is `Object.is`-identical to the last
+ * delivered one does not fire, so a unit can buffer working fields in state
+ * behind an identity-stable projection.
*/
type ProjectionChangeListener = (
session: Session,
@@ -95,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event; `apply` must return the same reference when its state did not change.
+`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the last value the feed delivered, so a unit can buffer working fields in state behind an identity-stable projection; `apply` must return the same reference when its state did not change.
## The registry: `ctx.sessionProjections`
@@ -177,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the last delivered one (identity-stable projections stay quiet). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md
index 763798cb8d..36b6c874b4 100644
--- a/docs/subsystems/session-projection.zh.md
+++ b/docs/subsystems/session-projection.zh.md
@@ -83,9 +83,12 @@ interface ProjectionSnapshot {
```ts type-equiv
/**
- * Change-feed listener: one unit's value changed for one session. `value` is
- * the schema-validated `view` output; `seq` is the unit's watermark at
- * emission (the seq of the event that caused the change).
+ * Change-feed listener: one unit's served value changed for one session.
+ * `value` is the schema-validated `view` output; `seq` is the unit's
+ * watermark at emission (the seq of the event that caused the change). A
+ * changed state whose raw `view` output is `Object.is`-identical to the last
+ * delivered one does not fire, so a unit can buffer working fields in state
+ * behind an identity-stable projection.
*/
type ProjectionChangeListener = (
session: Session,
@@ -95,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次;状态未变时,`apply` 必须返回同一引用。
+`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与变更流上一次交付的值 `Object.is` 相同,因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静;状态未变时,`apply` 必须返回同一引用。
## 注册表:`ctx.sessionProjections`
@@ -177,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the last delivered one (identity-stable projections stay quiet). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 9d22fdcfeb..f1a53af785 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'sessionProjections',
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
- description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
+ description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the last delivered one (identity-stable projections stay quiet). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
methods: [
{
signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void',
diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml
index 6303cfad25..c197c6da85 100644
--- a/packages/session/session-projection/README.i18n.yaml
+++ b/packages/session/session-projection/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
-README.md: c379abbfbdf145be4dcd7b4825ca349bc7a60f71
-README.zh.md: 17d38e612c778a64f228442f704335e0dc78562e
+README.md: 0268f05ef49c27082478e19941e5b3888cbce46e
+README.zh.md: 286aeebf0ffdf5b5d1c4eeadc00d9df64c26daf7
diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md
index c379abbfbd..0268f05ef4 100644
--- a/packages/session/session-projection/README.md
+++ b/packages/session/session-projection/README.md
@@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable
### Design concept
-The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` — a unit that returns the same state reference costs one call and nothing downstream. Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
+The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the last delivered one stays quiet (so a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
### Source map
diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md
index 17d38e612c..286aeebf0f 100644
--- a/packages/session/session-projection/README.zh.md
+++ b/packages/session/session-projection/README.zh.md
@@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
### 设计理念
-本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关——返回同一状态引用的单元只花一次调用,不产生任何下游工作。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
+本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上次交付相同的单元同样保持安静(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
### 源码地图
diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts
index 0a11f037b6..f48df406ec 100644
--- a/packages/session/session-projection/src/index.ts
+++ b/packages/session/session-projection/src/index.ts
@@ -83,9 +83,12 @@ export interface ProjectionDefinition<
}
/**
- * Change-feed listener: one unit's value changed for one session. `value` is
- * the schema-validated `view` output; `seq` is the unit's watermark at
- * emission (the seq of the event that caused the change).
+ * Change-feed listener: one unit's served value changed for one session.
+ * `value` is the schema-validated `view` output; `seq` is the unit's
+ * watermark at emission (the seq of the event that caused the change). A
+ * changed state whose raw `view` output is `Object.is`-identical to the last
+ * delivered one does not fire, so a unit can buffer working fields in state
+ * behind an identity-stable projection.
*/
export type ProjectionChangeListener = (
session: Session,
@@ -141,6 +144,13 @@ interface UnitCell {
state: unknown
/** Seq of the last event passed through `apply` (regardless of change). */
observedSeq: number
+ /**
+ * Raw (pre-validation) `view` output the change feed last delivered, when
+ * it has delivered one. A changed state whose raw view is `Object.is` to
+ * this stays quiet, so a unit can buffer working fields in state by
+ * keeping its wire projection identity-stable.
+ */
+ lastView?: { raw: unknown }
}
/**
@@ -166,7 +176,8 @@ interface Registration {
* service subscribes to `session/event` once; every committed event passes
* every registered unit's `apply` (eager drive), and a changed state
* reference in a client-visible unit notifies the change feed with the
- * schema-validated view.
+ * schema-validated view — unless the raw view output is `Object.is`-identical
+ * to the last delivered one (identity-stable projections stay quiet).
* Cells build lazily — a unit registered after events flowed, or a session
* older than the registry, folds `init` over the in-memory log on first
* touch (event or read). Registration is an effect (disposer rides the
@@ -629,7 +640,13 @@ export class SessionProjectionRegistry extends Service {
cell.state = next
cell.observedSeq = event.seq
if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
- const value = this.viewCell(registration, cell)
+ // Identity gate on the raw view: a changed state whose projection is
+ // reference-identical to the last delivered one stays quiet, so a
+ // unit can buffer working fields without spamming the feed.
+ const raw = registration.def.wire.view(cell.state)
+ if (cell.lastView !== undefined && Object.is(cell.lastView.raw, raw)) continue
+ cell.lastView = { raw }
+ const value = registration.def.wire.viewSchema.parse(raw)
for (const listener of this.listeners) {
listener(session, registration.def.key as Extract, value, event.seq)
}
diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts
index d5d99b3cb4..7a421a2f9f 100644
--- a/packages/session/session-projection/tests/registry.spec.ts
+++ b/packages/session/session-projection/tests/registry.spec.ts
@@ -19,10 +19,12 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
'test/marks': MarksState
'test/count': number
+ 'test/buffered': { marks: string[]; draft: string }
}
interface SessionProjectionMap {
'test/marks': { marks: string[] }
+ 'test/buffered': string[]
}
}
@@ -113,6 +115,40 @@ describe('SessionProjectionRegistry drive', () => {
expect(seen).toEqual([{ key: 'test/marks', value: { marks: ['a'] }, seq: event.seq, sessionId: String(session.id) }])
})
+ it('keeps the feed quiet while a changed state serves an identity-stable view (draft buffering)', async () => {
+ const { ctx, session } = await harness()
+ // Unit buffering a working field beside its wire array: the view projects
+ // only `marks`, whose identity survives draft-only applies.
+ ctx.sessionProjections.register({
+ key: 'test/buffered',
+ stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }),
+ init: () => ({ marks: [], draft: '' }),
+ apply: (state, event) => {
+ if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' }
+ if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` }
+ return state
+ },
+ wire: { viewSchema: z.array(z.string()), view: state => state.marks },
+ stateVersion: 1,
+ })
+ const seen: { value: unknown; seq: number }[] = []
+ ctx.sessionProjections.onChanged((_session, key, value, seq) => {
+ if (key === 'test/buffered') seen.push({ value, seq })
+ })
+ const first = mark(session, ['a'])
+ // Draft-only applies change the state reference but not the served view.
+ session.append('turn/start', { turn: 1 })
+ session.append('turn/start', { turn: 2 })
+ const second = mark(session, ['a', 'b'])
+ expect(seen).toEqual([
+ { value: ['a'], seq: first.seq },
+ { value: ['a', 'b'], seq: second.seq },
+ ])
+ // The quiet applies still advanced the state itself.
+ expect(ctx.sessionProjections.stateOf(session, 'test/buffered')?.draft).toBe('')
+ expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b'])
+ })
+
it('drives independently per session (cells are per-session watermarks)', async () => {
const { ctx, session } = await harness()
const other = ctx.sessions.create()
From 0e63841189c9facdc5ec0213db4f2eec8d58b044 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 14:28:42 +0800
Subject: [PATCH 12/26] feat(session-turn-outline): settled-response previews
at card budgets
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Outline entries gain the turn's final text-bearing assistant preview:
each assistant message overwrites a state draft and turn/end commits
the survivor, matching the loaded rail's findLast semantic; the bare-
array wire keeps its identity across draft changes, so pushes stay at
three per turn. Preview budgets shrink to the rail card's clamps — one
50-character prompt line, up to three 120-character response lines,
ellipsis on clip — on loaded and unloaded turns alike (stateVersion 2
discards v1 cache rows).
---
...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +-
.../2026-08-30-web-turn-rail-outline-jump.md | 6 +-
...026-08-30-web-turn-rail-outline-jump.zh.md | 6 +-
apps/web/tests/chat-scroll-contract.e2e.ts | 8 +-
packages/client/ui-chat/README.i18n.yaml | 4 +-
packages/client/ui-chat/README.md | 2 +-
packages/client/ui-chat/README.zh.md | 2 +-
.../src/client/chat/TurnNavigator.module.css | 4 +-
.../src/client/chat/turn-rail-items.ts | 28 ++--
.../conversation-nodes/turn-navigation.ts | 30 +++--
.../ui-chat/tests/chat-view.client.spec.tsx | 47 +++----
...nversation-node-definitions.client.spec.ts | 6 +-
.../tests/turn-rail-items.client.spec.ts | 60 ++++-----
.../session-turn-outline/README.i18n.yaml | 4 +-
.../session/session-turn-outline/README.md | 21 +--
.../session/session-turn-outline/README.zh.md | 21 +--
.../session-turn-outline/src/projection.ts | 101 +++++++++-----
.../session/session-turn-outline/src/types.ts | 24 +++-
.../tests/loader-composition.spec.ts | 13 +-
.../tests/projection.spec.ts | 124 ++++++++++++------
20 files changed, 318 insertions(+), 197 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
index d78900be1b..cd2ef7bb8f 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
-2026-08-30-web-turn-rail-outline-jump.md: eb4e7f09abd66135d0aef1175729e9493b9b6db2
-2026-08-30-web-turn-rail-outline-jump.zh.md: 6851456667d0b9f6d565b941189385b7392a5503
+2026-08-30-web-turn-rail-outline-jump.md: 7fa2ba4ee2a09a78b277b238d215413e60dc9a54
+2026-08-30-web-turn-rail-outline-jump.zh.md: 544c43715217e1beda925eb6919cfbd3d5df3907
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
index eb4e7f09ab..7fa2ba4ee2 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
@@ -12,7 +12,9 @@ The web chat's turn rail derived its marks from the loaded event window, and the
Three cooperating pieces, each useful alone.
-**Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends `{turn, seq, prompt: ''}` (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), and the turn's first human `user/message` fills a 160-character prompt preview mirroring the rail's loaded-turn preview semantics. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn.
+**Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn.
+
+**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output against the last delivered one and stays quiet when `Object.is`-identical. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
**Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
@@ -30,4 +32,4 @@ Three cooperating pieces, each useful alone.
## Consequences
-The rail is now session-scoped rather than window-scoped, at the cost of a whole-value projection that grows with the session (~200 bytes per turn, pushed at most twice per turn); splitting previews into an on-demand read is deferred until multi-thousand-turn sessions need it. A deep jump still loads every intervening page — the contiguous-window contract — so jumping to turn 1 of a huge session materializes the whole transcript, as manual paging always did. Assemblies without the projection plugin keep the old loaded-only rail. Coverage: projection unit + Loader-composition + HMR specs in the new package, `loadThrough` loop specs in session-controller, merge and jump specs in ui-chat (including the settle correction and busy lifecycle), and a browser contract in the chat-scroll e2e that drives a keyboard jump from an 88-turn fixture's tail to its unloaded first turn and asserts the landing geometry and rail fades.
+The rail is now session-scoped rather than window-scoped, at the cost of a whole-value projection that grows with the session (up to ~600 bytes per turn at full CJK preview budgets, pushed at most three times per turn); splitting previews into an on-demand read is deferred until multi-thousand-turn sessions need it. A deep jump still loads every intervening page — the contiguous-window contract — so jumping to turn 1 of a huge session materializes the whole transcript, as manual paging always did. Assemblies without the projection plugin keep the old loaded-only rail. Coverage: projection unit + Loader-composition + HMR specs in the new package, `loadThrough` loop specs in session-controller, merge and jump specs in ui-chat (including the settle correction and busy lifecycle), and a browser contract in the chat-scroll e2e that drives a keyboard jump from an 88-turn fixture's tail to its unloaded first turn and asserts the landing geometry and rail fades.
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
index 6851456667..544c437152 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
@@ -12,7 +12,9 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口
三个相互配合、各自独立可用的部分。
-**数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加 `{turn, seq, prompt: ''}`(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入 160 字符的提示词预览,语义与导航栏已加载轮次的预览一致。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。
+**数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。
+
+**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把原始 `view` 输出与上一次交付的值比较,`Object.is` 相同即保持安静。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
**分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
@@ -30,4 +32,4 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口
## Consequences
-导航栏从窗口口径变为会话口径,代价是随会话增长的整值投影(约每轮 200 字节,每轮至多推送两次);把预览拆成按需读取推迟到数千轮量级的会话真正需要时。深跳仍会加载沿途所有页——连续窗口契约——跳到超长会话的第 1 轮会实体化整个 transcript,与手动翻页的终态相同。未挂载该投影插件的装配保留旧的仅已加载导航。覆盖:新包的投影单元 + Loader 组合 + HMR 测试、session-controller 的 loadThrough 循环测试、ui-chat 的合并与跳转测试(含 settle 校正与忙碌生命周期),以及 chat-scroll e2e 里的浏览器契约——在 88 轮 fixture 的尾部用键盘跳到未加载的第 1 轮,断言落点几何与导航栏渐变。
+导航栏从窗口口径变为会话口径,代价是随会话增长的整值投影(全中文预览预算下每轮上限约 600 字节,每轮至多推送三次);把预览拆成按需读取推迟到数千轮量级的会话真正需要时。深跳仍会加载沿途所有页——连续窗口契约——跳到超长会话的第 1 轮会实体化整个 transcript,与手动翻页的终态相同。未挂载该投影插件的装配保留旧的仅已加载导航。覆盖:新包的投影单元 + Loader 组合 + HMR 测试、session-controller 的 loadThrough 循环测试、ui-chat 的合并与跳转测试(含 settle 校正与忙碌生命周期),以及 chat-scroll e2e 里的浏览器契约——在 88 轮 fixture 的尾部用键盘跳到未加载的第 1 轮,断言落点几何与导航栏渐变。
diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts
index 3058f669c5..37996ef301 100644
--- a/apps/web/tests/chat-scroll-contract.e2e.ts
+++ b/apps/web/tests/chat-scroll-contract.e2e.ts
@@ -582,9 +582,15 @@ describe('web e2e: long Chat scroll contract', () => {
await expect.poll(() => rail.locator('[class*="fadeTop"]').count(), { timeout: 15_000 }).toBe(1)
// Activate the unloaded mark by keyboard: pointer input belongs to the
- // rail frame, while each mark is the keyboard/AT destination.
+ // rail frame, while each mark is the keyboard/AT destination. Focus
+ // first shows the outline-backed preview: prompt and settled response
+ // both travel ahead of the events.
const beforeRows = await loadedFlowRows(world.page)
await firstUnloaded.focus()
+ const tooltip = world.page.getByRole('tooltip')
+ await expect.poll(() => tooltip.count(), { timeout: 15_000 }).toBe(1)
+ expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.user(1))
+ expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.assistant(1))
await world.page.keyboard.press('Enter')
// The jump pages history in and lands on turn 1: its mark flips to the
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 293c0fcc41..2e8df9e284 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 0df0ef77b81fcaa16a197633be04d6e6c0d09b71
-README.zh.md: b35443c6207776a18cd85602ffb968a539babbee
+README.md: 756b09ee1bc1f849d507bc06faad6d5365f0b851
+README.zh.md: 6e6278c5fe3f7a31c4e7f4485aa6ebe0dcf95f60
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index 0df0ef77b8..756b09ee1b 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -64,7 +64,7 @@ None; Chat presentation does not assemble or mutate provider requests.
- **The transcript reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. Turn navigation is wider than the window: the rail merges the loaded Turns with the host `turnOutline` projection, so every started Turn gets a fixed-pitch mark (10px apart; a ladder taller than the frame scrolls inside it with gradient fades), and activating an unloaded mark pages history through the Turn's `turn/start` seq before landing on its row. Without the projection (assemblies not mounting `dsh-session-turn-outline`) the rail falls back to loaded Turns only.
-- **Unloaded marks preview the prompt only** — the outline carries no response text, so an unloaded Turn's hover preview shows its first prompt (or just the Turn number) until its events load.
+- **Rail previews are card-sized** — one prompt line (50 characters) and up to three response lines (120), on loaded and unloaded Turns alike; an unloaded Turn's response arrives from the outline only once the Turn settled, so an open Turn previews its prompt (or just the Turn number) until then.
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index b35443c620..6e6278c5fe 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -64,7 +64,7 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。读者
- **transcript 只反映已加载的 Session 窗口**——只有 Session Controller 加载前一页 event 后,更早的 transcript node 才会出现。轮次导航比窗口更宽:轨道把已加载的 Turn 与宿主 `turnOutline` 投影合并,每个已开始的 Turn 都有固定间距刻度(相隔 10px;阶梯高于外框时在框内滚动并以渐变淡出标示可滚方向),激活未加载刻度会先把历史分页拉到该 Turn 的 `turn/start` seq 再落到它的行上。没有该投影时(未挂载 `dsh-session-turn-outline` 的装配),轨道回退到仅显示已加载 Turn。
-- **未加载刻度只预览提示词**——大纲不含回复文本,未加载 Turn 的悬浮预览在其事件载入前只显示首条提示词(或仅轮次号)。
+- **导航预览按卡片尺寸截断**——提示词一行(50 字符)、回复至多三行(120 字符),已加载与未加载 Turn 一致;未加载 Turn 的回复要等该轮落定后才随大纲到达,进行中的轮次在此之前只预览提示词(或仅轮次号)。
diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css
index c821c2393b..5e771e6727 100644
--- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css
+++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css
@@ -186,14 +186,14 @@
.previewPrompt {
font: var(--dsw-font-xs-strong-13);
- -webkit-line-clamp: 2;
+ -webkit-line-clamp: 1;
}
.previewResponse {
margin-top: 4px;
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xxs-12);
- -webkit-line-clamp: 2;
+ -webkit-line-clamp: 3;
}
@keyframes dsh-turn-mark-enter {
diff --git a/packages/client/ui-chat/src/client/chat/turn-rail-items.ts b/packages/client/ui-chat/src/client/chat/turn-rail-items.ts
index 349041dac9..c7ce212563 100644
--- a/packages/client/ui-chat/src/client/chat/turn-rail-items.ts
+++ b/packages/client/ui-chat/src/client/chat/turn-rail-items.ts
@@ -14,7 +14,7 @@ export interface TurnRailItem {
readonly turn: number
/** Bounded prompt preview (loaded window first, outline fallback). */
readonly prompt: string
- /** Bounded response preview; `''` for unloaded Turns (the outline carries prompts only). */
+ /** Bounded response preview (loaded window first, outline fallback). */
readonly response: string
/** How the rail reaches the Turn. */
readonly anchor:
@@ -25,27 +25,31 @@ export interface TurnRailItem {
const EMPTY_ITEMS: readonly TurnRailItem[] = []
/** Structurally narrow one wire outline entry (projection values cross the wire). */
-function outlineEntry(value: unknown): { turn: number; seq: number; prompt: string } | undefined {
+function outlineEntry(value: unknown): { turn: number; seq: number; prompt: string; response: string } | undefined {
if (typeof value !== 'object' || value === null) return undefined
- const entry = value as { turn?: unknown; seq?: unknown; prompt?: unknown }
+ const entry = value as { turn?: unknown; seq?: unknown; prompt?: unknown; response?: unknown }
if (typeof entry.turn !== 'number' || !Number.isSafeInteger(entry.turn) || entry.turn < 0) return undefined
if (typeof entry.seq !== 'number' || !Number.isSafeInteger(entry.seq) || entry.seq < 0) return undefined
if (typeof entry.prompt !== 'string') return undefined
- return { turn: entry.turn, seq: entry.seq, prompt: entry.prompt }
+ return {
+ turn: entry.turn,
+ seq: entry.seq,
+ prompt: entry.prompt,
+ response: typeof entry.response === 'string' ? entry.response : '',
+ }
}
/** Wire outline entries, or none when the projection is absent or malformed. */
function outlineEntries(outline: unknown): readonly unknown[] {
- if (typeof outline !== 'object' || outline === null) return EMPTY_ITEMS
- const turns = (outline as { turns?: unknown }).turns
- return Array.isArray(turns) ? turns : EMPTY_ITEMS
+ return Array.isArray(outline) ? outline : EMPTY_ITEMS
}
/**
* Merge the host outline with the loaded rail items into the full ladder.
- * A turn present in both sides keeps the loaded anchor and response, taking
- * the outline prompt only when the window started mid-Turn (empty loaded
- * preview); turns on one side only pass through. Result ascends by turn.
+ * A turn present in both sides keeps the loaded anchor, taking an outline
+ * preview only where the window's own is empty (a mid-Turn window head, or a
+ * turn whose loaded nodes carry no text); turns on one side only pass
+ * through. Result ascends by turn.
* @param loaded - loaded-window rail items (timeline order).
* @param outline - `turnOutline` projection value, treated as wire data.
* @returns every known turn, ascending; a stable empty array when none.
@@ -61,7 +65,7 @@ export function mergeTurnRailItems(
byTurn.set(entry.turn, {
turn: entry.turn,
prompt: entry.prompt,
- response: '',
+ response: entry.response,
anchor: { kind: 'unloaded', seq: entry.seq },
})
}
@@ -70,7 +74,7 @@ export function mergeTurnRailItems(
byTurn.set(item.turn, {
turn: item.turn,
prompt: item.prompt !== '' ? item.prompt : preview?.prompt ?? '',
- response: item.response,
+ response: item.response !== '' ? item.response : preview?.response ?? '',
anchor: { kind: 'loaded', key: item.anchorKey },
})
}
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts
index 9d54e058c1..ac6b51e323 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts
@@ -2,30 +2,42 @@ import type { ChatNode } from '../contract/chat-nodes.ts'
import type { ChatLocationNodeIndex, ChatNodeStore, TurnNavigationItem } from '../contract/snapshot.ts'
/**
- * Preview budget per field. The rail clamps two short lines, so anything past
- * this is invisible; copying whole transcripts into navigation state would
+ * Preview budgets, sized to the rail card's clamps (one prompt line, up to
+ * three response lines) and mirrored by the turnOutline projection so a turn
+ * shows the same words before and after its events load. Anything past a
+ * budget is invisible; copying whole transcripts into navigation state would
* otherwise grow with the loaded window on every structural update.
*/
-const PREVIEW_LIMIT = 160
+const PROMPT_PREVIEW_LIMIT = 50
+const RESPONSE_PREVIEW_LIMIT = 120
-/** Join rendered text until the preview budget is met, then stop reading. */
-function preview(parts: Iterable): string {
+/** Join rendered text, collapse whitespace, and cap at `limit` with a trailing ellipsis when clipped. */
+function preview(parts: Iterable, limit: number): string {
let text = ''
+ let unread = false
for (const part of parts) {
+ if (text.length >= limit * 2) {
+ unread = true
+ break
+ }
text += text === '' ? part : ` ${part}`
- if (text.length >= PREVIEW_LIMIT) break
}
- return text.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_LIMIT)
+ const normalized = text.replace(/\s+/g, ' ').trim()
+ if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…`
+ return unread ? `${normalized}…` : normalized
}
function promptText(node: ChatNode): string {
if (node.kind !== 'user') return ''
- return preview(node.data.content.flatMap(block => block.type === 'text' ? [block.text] : []))
+ return preview(node.data.content.flatMap(block => block.type === 'text' ? [block.text] : []), PROMPT_PREVIEW_LIMIT)
}
function responseText(node: ChatNode): string {
if (node.kind !== 'assistant-step') return ''
- return preview(node.data.blocks.flatMap(block => block.kind === 'text' ? [block.text] : []))
+ return preview(
+ node.data.blocks.flatMap(block => block.kind === 'text' ? [block.text] : []),
+ RESPONSE_PREVIEW_LIMIT,
+ )
}
/**
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 6be1416036..14489a3d37 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -605,20 +605,20 @@ describe('ChatView', () => {
it('extends the rail with unloaded outline turns, pages on click, and falls back when nothing lands', async () => {
const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
const h = makeHarness({ nodes: later }, { hasMore: true })
- h.setOutline({
- turns: [
- { turn: 1, seq: 0, prompt: 'first prompt from outline' },
- { turn: 2, seq: 4, prompt: 'second prompt from outline' },
- { turn: 3, seq: 8, prompt: 'third prompt' },
- ],
- })
+ h.setOutline([
+ { turn: 1, seq: 0, prompt: 'first prompt from outline', response: 'first answer from outline' },
+ { turn: 2, seq: 4, prompt: 'second prompt from outline', response: '' },
+ { turn: 3, seq: 8, prompt: 'third prompt', response: 'third response' },
+ ])
const view = render( )
const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
view.getByRole('button', { name: '加载并跳转到第 2 轮' })
const third = view.getByRole('button', { name: '跳转到第 3 轮' })
expect(third.getAttribute('aria-current')).toBe('true')
fireEvent.focus(first)
+ // An unloaded turn previews both sides from the outline.
expect(view.getByRole('tooltip').textContent).toContain('first prompt from outline')
+ expect(view.getByRole('tooltip').textContent).toContain('first answer from outline')
fireEvent.click(first)
expect(h.loadThrough).toHaveBeenCalledWith(0)
@@ -635,12 +635,10 @@ describe('ChatView', () => {
it('a jump from the pinned tail releases bottom ownership so the follow snap cannot cancel it', async () => {
const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
const h = makeHarness({ nodes: later }, { hasMore: true })
- h.setOutline({
- turns: [
- { turn: 1, seq: 0, prompt: 'first prompt' },
- { turn: 3, seq: 8, prompt: 'third prompt' },
- ],
- })
+ h.setOutline([
+ { turn: 1, seq: 0, prompt: 'first prompt', response: '' },
+ { turn: 3, seq: 8, prompt: 'third prompt', response: '' },
+ ])
let releaseJump: (() => void) | undefined
h.loadThrough.mockImplementation(() => new Promise((resolve) => { releaseJump = resolve }))
const view = render( )
@@ -664,13 +662,12 @@ describe('ChatView', () => {
{ nodes: [userInTurn(8, 'latest prompt', 60), assistant(9, 'latest response', 60)] },
{ hasMore: true },
)
- h.setOutline({
- turns: Array.from({ length: 60 }, (_, index) => ({
- turn: index + 1,
- seq: index * 4,
- prompt: `p${String(index + 1)}`,
- })),
- })
+ h.setOutline(Array.from({ length: 60 }, (_, index) => ({
+ turn: index + 1,
+ seq: index * 4,
+ prompt: `p${String(index + 1)}`,
+ response: '',
+ })))
const view = render( )
const nav = view.getByRole('navigation', { name: '轮次导航' })
// 60 marks at the fixed 10px pitch: the ladder keeps its natural height.
@@ -699,12 +696,10 @@ describe('ChatView', () => {
it('lands a jump on its turn once the paged rows commit', async () => {
const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
const h = makeHarness({ nodes: later }, { hasMore: true })
- h.setOutline({
- turns: [
- { turn: 1, seq: 0, prompt: 'first prompt' },
- { turn: 3, seq: 8, prompt: 'third prompt' },
- ],
- })
+ h.setOutline([
+ { turn: 1, seq: 0, prompt: 'first prompt', response: '' },
+ { turn: 3, seq: 8, prompt: 'third prompt', response: '' },
+ ])
let releaseJump: (() => void) | undefined
h.loadThrough.mockImplementation(() => new Promise((resolve) => { releaseJump = resolve }))
const view = render( )
diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
index f9ad400efe..00484dc6c5 100644
--- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
+++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
@@ -214,14 +214,16 @@ describe('built-in conversation node Definitions', () => {
expect(streamed).not.toBe(opening)
})
- it('bounds each rail preview instead of copying the whole transcript', () => {
+ it('bounds each rail preview at its card budget instead of copying the whole transcript', () => {
const long = 'x'.repeat(400)
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'user/message', textMessage('user-1', long), { surfaceOp: 'append' }),
])
const items = snapshot(value).navigation.items()
- expect(items[0]?.prompt.length).toBe(160)
+ // One clipped prompt line: 49 characters plus the trailing ellipsis.
+ expect(items[0]?.prompt.length).toBe(50)
+ expect(items[0]?.prompt.endsWith('…')).toBe(true)
})
it('classifies reply content separately from reasoning and Tool protocol blocks', () => {
diff --git a/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts b/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts
index f53aeebd72..598bc198b2 100644
--- a/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts
+++ b/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts
@@ -10,36 +10,37 @@ function loadedItem(turn: number, prompt = `p${String(turn)}`, response = `r${St
describe('mergeTurnRailItems', () => {
it('returns a stable empty array when both sides are empty', () => {
- expect(mergeTurnRailItems([], undefined)).toBe(mergeTurnRailItems([], { turns: [] }))
+ expect(mergeTurnRailItems([], undefined)).toBe(mergeTurnRailItems([], []))
})
- it('maps outline-only turns to unloaded marks in ascending order', () => {
- const items = mergeTurnRailItems([], {
- turns: [
- { turn: 1, seq: 0, prompt: 'first' },
- { turn: 2, seq: 9, prompt: '' },
- ],
- })
+ it('maps outline-only turns to unloaded marks with both previews in ascending order', () => {
+ const items = mergeTurnRailItems([], [
+ { turn: 1, seq: 0, prompt: 'first', response: 'first answer' },
+ { turn: 2, seq: 9, prompt: '', response: '' },
+ ])
expect(items).toEqual([
- { turn: 1, prompt: 'first', response: '', anchor: { kind: 'unloaded', seq: 0 } },
+ { turn: 1, prompt: 'first', response: 'first answer', anchor: { kind: 'unloaded', seq: 0 } },
{ turn: 2, prompt: '', response: '', anchor: { kind: 'unloaded', seq: 9 } },
])
})
- it('prefers the loaded side on overlap but falls back to the outline prompt for a mid-Turn window head', () => {
+ it('prefers the loaded side on overlap but fills empty previews from the outline', () => {
const items = mergeTurnRailItems(
- [loadedItem(2, '', 'answer two'), loadedItem(3)],
- {
- turns: [
- { turn: 1, seq: 0, prompt: 'one' },
- { turn: 2, seq: 8, prompt: 'two from outline' },
- { turn: 3, seq: 16, prompt: 'three from outline' },
- ],
- },
+ [loadedItem(2, '', ''), loadedItem(3)],
+ [
+ { turn: 1, seq: 0, prompt: 'one', response: 'answer one' },
+ { turn: 2, seq: 8, prompt: 'two from outline', response: 'answer two from outline' },
+ { turn: 3, seq: 16, prompt: 'three from outline', response: 'answer three from outline' },
+ ],
)
expect(items).toEqual([
- { turn: 1, prompt: 'one', response: '', anchor: { kind: 'unloaded', seq: 0 } },
- { turn: 2, prompt: 'two from outline', response: 'answer two', anchor: { kind: 'loaded', key: 'anchor-2' } },
+ { turn: 1, prompt: 'one', response: 'answer one', anchor: { kind: 'unloaded', seq: 0 } },
+ {
+ turn: 2,
+ prompt: 'two from outline',
+ response: 'answer two from outline',
+ anchor: { kind: 'loaded', key: 'anchor-2' },
+ },
{ turn: 3, prompt: 'p3', response: 'r3', anchor: { kind: 'loaded', key: 'anchor-3' } },
])
})
@@ -48,7 +49,7 @@ describe('mergeTurnRailItems', () => {
expect(mergeTurnRailItems([loadedItem(7)], undefined)).toEqual([
{ turn: 7, prompt: 'p7', response: 'r7', anchor: { kind: 'loaded', key: 'anchor-7' } },
])
- expect(mergeTurnRailItems([loadedItem(4)], { turns: [{ turn: 3, seq: 1, prompt: 'older' }] })).toEqual([
+ expect(mergeTurnRailItems([loadedItem(4)], [{ turn: 3, seq: 1, prompt: 'older', response: '' }])).toEqual([
{ turn: 3, prompt: 'older', response: '', anchor: { kind: 'unloaded', seq: 1 } },
{ turn: 4, prompt: 'p4', response: 'r4', anchor: { kind: 'loaded', key: 'anchor-4' } },
])
@@ -58,15 +59,14 @@ describe('mergeTurnRailItems', () => {
expect(mergeTurnRailItems([loadedItem(1)], 'not an outline')).toEqual([
{ turn: 1, prompt: 'p1', response: 'r1', anchor: { kind: 'loaded', key: 'anchor-1' } },
])
- const items = mergeTurnRailItems([], {
- turns: [
- { turn: -1, seq: 0, prompt: 'negative turn' },
- { turn: 2, seq: 0.5, prompt: 'fractional seq' },
- { turn: 3, seq: 4, prompt: 5 },
- { turn: 6, seq: 7, prompt: 'kept' },
- null,
- ],
- })
+ const items = mergeTurnRailItems([], [
+ { turn: -1, seq: 0, prompt: 'negative turn', response: '' },
+ { turn: 2, seq: 0.5, prompt: 'fractional seq', response: '' },
+ { turn: 3, seq: 4, prompt: 5, response: '' },
+ { turn: 6, seq: 7, prompt: 'kept', response: 8 },
+ null,
+ ])
+ // A non-string response degrades to '' while the entry itself survives.
expect(items).toEqual([
{ turn: 6, prompt: 'kept', response: '', anchor: { kind: 'unloaded', seq: 7 } },
])
diff --git a/packages/session/session-turn-outline/README.i18n.yaml b/packages/session/session-turn-outline/README.i18n.yaml
index c27b36d44a..1deca10f7a 100644
--- a/packages/session/session-turn-outline/README.i18n.yaml
+++ b/packages/session/session-turn-outline/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-turn-outline/README.md
-README.md: f56445df70fcfd637657c4f7640dd5c415af7063
-README.zh.md: f10a0a61d9df6534d8e2f9566814c6112e2e678e
+README.md: e02345d01ed4d31a3e20fd3e28d5b90d451618bb
+README.zh.md: d60e70e4b6f72da076f8fca6c9942815448f92e4
diff --git a/packages/session/session-turn-outline/README.md b/packages/session/session-turn-outline/README.md
index f56445df70..e02345d01e 100644
--- a/packages/session/session-turn-outline/README.md
+++ b/packages/session/session-turn-outline/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-`dsh-session-turn-outline` serves the whole-log turn outline — every started turn with its `turn/start` seq and a bounded first-prompt preview — as the `turnOutline` projection unit. A client that pages history in windows reads the outline to offer every turn of the session (loaded or not) and to target its backwards paging at the exact seq that brings a turn's events in. Choose it in compositions that already mount the projection registry, such as the web app bundle whose chat turn rail is the reference consumer; assemblies without the registry are unaffected and their consumers fall back to loaded-window navigation. Setup and entry semantics come first; the fold internals live in a collapsible developer section below.
+`dsh-session-turn-outline` serves the whole-log turn outline — every started turn with its `turn/start` seq and bounded prompt and final-response previews — as the `turnOutline` projection unit. A client that pages history in windows reads the outline to offer every turn of the session (loaded or not) and to target its backwards paging at the exact seq that brings a turn's events in. Choose it in compositions that already mount the projection registry, such as the web app bundle whose chat turn rail is the reference consumer; assemblies without the registry are unaffected and their consumers fall back to loaded-window navigation. Setup and entry semantics come first; the fold internals live in a collapsible developer section below.
## Table of Contents
@@ -41,9 +41,10 @@ Mount the plugin beside the session store and the projection registry when clien
|---|---|
| `turn` | Host-assigned turn number from the `turn/start` payload |
| `seq` | The turn's `turn/start` event seq — paging a window back through this seq loads the whole turn |
-| `prompt` | Preview of the turn's first human prompt (space-joined text blocks, collapsed whitespace, 160-character cap); `''` until an eligible prompt lands |
+| `prompt` | Preview of the turn's first human prompt (space-joined text blocks, collapsed whitespace, 50-character cap with a trailing ellipsis when clipped — one rail-card line); `''` until an eligible prompt lands |
+| `response` | Preview of the turn's final text-bearing assistant message (same normalization, 120-character cap — up to three rail-card lines); `''` until the turn ends with assistant text |
-Entries are strictly increasing by `turn`, and the wire value is the complete outline (whole-value rule): consumers replace, never merge. Only `user/message` events with the human `user` source fill previews, so injected context and tool results never leak into navigation; a turn whose prompt is images-only keeps `''` and consumers label it by number. The preview budget matches the chat rail's loaded-turn preview, so a turn shows the same words before and after its events load.
+The wire value is the complete entry array, strictly increasing by `turn` (whole-value rule): consumers replace, never merge. Prompts fill only from `user/message` events with the human `user` source, so injected context and tool results never leak into navigation; a turn whose prompt is images-only keeps `''` and consumers label it by number. The response buffers as a draft while its turn streams and commits at `turn/end`; the change feed's raw-view identity gate keeps draft-only changes quiet, so the outline pushes at most three times per turn — boundary, prompt, settled response. Preview budgets match the chat rail's loaded-turn previews, so a turn shows the same words before and after its events load.
### Failures and recovery
@@ -61,7 +62,7 @@ This section explains the fold behind the outline; the observable behavior is fu
### Design concept
-The unit is a pure fold over committed session events. `turn/start` — not the prompt `user/message` — anchors each entry because its seq is the load-through target for a jump: the agent loop logs `turn/start` before the turn's prompt and steps, so a window paged back through that seq contains the whole turn. The preview then fills from the first human `user/message`, and only while the newest entry is still empty — later human messages in the same turn (steering) keep the first preview.
+The unit is a pure fold over committed session events. `turn/start` — not the prompt `user/message` — anchors each entry because its seq is the load-through target for a jump: the agent loop logs `turn/start` before the turn's prompt and steps, so a window paged back through that seq contains the whole turn. The prompt fills from the first human `user/message`, and only while the newest entry is still empty — later human messages in the same turn (steering) keep the first preview. The response cannot fill the same way (`turn/end` carries no text), so each text-bearing `assistant/message` overwrites a state draft and `turn/end` commits the survivor — the newest text, which is the loaded rail's `findLast` semantic.
### Source map
@@ -73,9 +74,9 @@ The unit is a pure fold over committed session events. `turn/start` — not the
### Fold rules
-- Uninteresting events return the same state reference; the registry's `Object.is` gate keeps the change feed quiet — the outline moves at most twice per turn.
-- A `turn/start` that does not advance the turn number is skipped, keeping the outline sorted; a retried boundary's prompt then lands on the standing entry.
-- State and wire view are the same value, so the persisted-cache state schema is the wire schema.
+- Uninteresting events return the same state reference, and draft-only changes keep the `turns` array's identity; the registry's two `Object.is` gates then hold the feed to at most three pushes per turn.
+- A `turn/start` that does not advance the turn number is skipped, keeping the outline sorted; a retried boundary's previews then land on the standing entry.
+- The wire view projects `state.turns`; the persisted-cache state schema wraps the wire schema with the draft field.
@@ -108,9 +109,9 @@ None; the package never assembles or sends provider requests.
These limits define what the outline describes and when the unit is absent. They are current package constraints.
-- **The wire value grows with the session** — every change pushes the complete outline (whole-value rule), roughly 200 bytes per turn; splitting previews into an on-demand read is deferred until sessions with many thousands of turns need it.
-- **Previews carry the prompt only** — assistant-response previews stay window-scoped in the consumer; the outline never re-reads message bodies.
-- **A turn without an eligible text prompt keeps `''`** — images-only and command-only turns are navigable but labeled by number.
+- **The wire value grows with the session** — every push carries the complete outline (whole-value rule), up to ~600 bytes per turn at full CJK budgets and typically far less; splitting previews into an on-demand read is deferred until sessions with many thousands of turns need it.
+- **The response previews only settled turns** — it commits at `turn/end`, so an open turn (or one whose end never logged) shows a prompt-only preview until the boundary lands.
+- **A turn without eligible text keeps `''`** — images-only and command-only turns are navigable but labeled by number, and a turn whose steps emit no text gets no response preview.
- **Mounted only where the projection registry is composed** — other assemblies serve no `turnOutline` key, and their consumers fall back to loaded-window navigation.
diff --git a/packages/session/session-turn-outline/README.zh.md b/packages/session/session-turn-outline/README.zh.md
index f10a0a61d9..d60e70e4b6 100644
--- a/packages/session/session-turn-outline/README.zh.md
+++ b/packages/session/session-turn-outline/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-`dsh-session-turn-outline` 以 `turnOutline` 投影单元提供全日志的轮次大纲——每个已开始的轮次连同其 `turn/start` seq 与有界的首条提示词预览。按窗口分页历史的客户端读取大纲即可提供会话的每一轮(无论是否已加载),并把向后分页精确定位到能载入某轮事件的 seq。在已挂载投影注册表的组合中选择它,例如以聊天轮次导航栏为参考消费者的 Web 应用包;没有注册表的装配不受影响,其消费者回退到仅按已加载窗口导航。用法与条目语义在前;折叠内部细节放在下方可折叠的开发者章节中。
+`dsh-session-turn-outline` 以 `turnOutline` 投影单元提供全日志的轮次大纲——每个已开始的轮次连同其 `turn/start` seq 以及有界的提示词与最终回复预览。按窗口分页历史的客户端读取大纲即可提供会话的每一轮(无论是否已加载),并把向后分页精确定位到能载入某轮事件的 seq。在已挂载投影注册表的组合中选择它,例如以聊天轮次导航栏为参考消费者的 Web 应用包;没有注册表的装配不受影响,其消费者回退到仅按已加载窗口导航。用法与条目语义在前;折叠内部细节放在下方可折叠的开发者章节中。
## 目录
@@ -41,9 +41,10 @@ kind: "package-reference"
|---|---|
| `turn` | `turn/start` 载荷里的宿主分配轮次号 |
| `seq` | 该轮 `turn/start` 事件的 seq——窗口向后分页越过此 seq 即载入整轮 |
-| `prompt` | 该轮首条人类提示词的预览(文本块以空格连接、空白折叠、160 字符封顶);合格提示词落日志前为 `''` |
+| `prompt` | 该轮首条人类提示词的预览(文本块以空格连接、空白折叠、50 字符封顶且截断时补省略号——即导航卡片一行);合格提示词落日志前为 `''` |
+| `response` | 该轮最后一条带文本的助手消息的预览(同样的归一化、120 字符封顶——即卡片至多三行);轮次带着助手文本结束前为 `''` |
-条目按 `turn` 严格递增,wire 值是完整大纲(整值规则):消费者整体替换,从不合并。只有带人类 `user` 来源的 `user/message` 事件才会填充预览,注入的上下文与工具结果绝不进入导航;纯图片提示词的轮次保持 `''`,消费者按轮次号标注。预览预算与聊天导航栏已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。
+wire 值是按 `turn` 严格递增的完整条目数组(整值规则):消费者整体替换,从不合并。提示词只从带人类 `user` 来源的 `user/message` 事件填充,注入的上下文与工具结果绝不进入导航;纯图片提示词的轮次保持 `''`,消费者按轮次号标注。回复在轮次流式期间缓冲为草稿、在 `turn/end` 落定;变更流的原始视图身份门让纯草稿变化保持安静,因此大纲每轮至多推送三次——开轮、提示词、落定回复。预览预算与聊天导航栏已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。
### 失败与恢复
@@ -61,7 +62,7 @@ kind: "package-reference"
### 设计理念
-该单元是对已提交会话事件的纯折叠。锚定每个条目的是 `turn/start` 而非提示词 `user/message`,因为它的 seq 就是跳转的载入目标:agent loop 先记 `turn/start` 再记该轮的提示词与步骤,窗口向后分页越过该 seq 即包含整轮。预览随后由首条人类 `user/message` 填充,且仅当最新条目仍为空时——同一轮内后续的人类消息(steering)保留首个预览。
+该单元是对已提交会话事件的纯折叠。锚定每个条目的是 `turn/start` 而非提示词 `user/message`,因为它的 seq 就是跳转的载入目标:agent loop 先记 `turn/start` 再记该轮的提示词与步骤,窗口向后分页越过该 seq 即包含整轮。提示词由首条人类 `user/message` 填充,且仅当最新条目仍为空时——同一轮内后续的人类消息(steering)保留首个预览。回复无法同样填充(`turn/end` 不带文本),所以每条带文本的 `assistant/message` 覆写状态里的草稿,`turn/end` 提交幸存者——最新的文本,与已加载导航栏 `findLast` 的语义一致。
### 源码地图
@@ -73,9 +74,9 @@ kind: "package-reference"
### 折叠规则
-- 不相关事件返回同一状态引用;注册表的 `Object.is` 门禁保持变更流安静——大纲每轮至多变动两次。
-- 未推进轮次号的 `turn/start` 被跳过,保持大纲有序;重试边界的提示词随后落在既有条目上。
-- 状态与 wire 视图是同一个值,因此持久缓存的状态 schema 就是 wire schema。
+- 不相关事件返回同一状态引用,纯草稿变化保持 `turns` 数组身份不变;注册表的两道 `Object.is` 门由此把变更流压到每轮至多三次推送。
+- 未推进轮次号的 `turn/start` 被跳过,保持大纲有序;重试边界的预览随后落在既有条目上。
+- wire 视图投影 `state.turns`;持久缓存的状态 schema 在 wire schema 外再包一个草稿字段。
@@ -108,9 +109,9 @@ kind: "package-reference"
这些限制说明大纲描述什么、单元何时缺失。它们是当前包约束。
-- **wire 值随会话增长**——每次变更推送完整大纲(整值规则),约每轮 200 字节;把预览拆成按需读取推迟到数千轮量级的会话真正需要时。
-- **预览只含提示词**——助手回复预览仍由消费者按窗口提供;大纲从不回读消息正文。
-- **没有合格文本提示词的轮次保持 `''`**——纯图片、纯命令的轮次可导航但按轮次号标注。
+- **wire 值随会话增长**——每次推送携带完整大纲(整值规则),全中文预算下每轮上限约 600 字节、通常远小于此;把预览拆成按需读取推迟到数千轮量级的会话真正需要时。
+- **回复只预览已落定的轮次**——它在 `turn/end` 提交,进行中的轮次(或从未记下结束边界的轮次)在边界落地前只有提示词预览。
+- **没有合格文本的轮次保持 `''`**——纯图片、纯命令的轮次可导航但按轮次号标注,步骤全程不产文本的轮次没有回复预览。
- **仅在组合了投影注册表时挂载**——其他装配不提供 `turnOutline` 键,其消费者回退到仅按已加载窗口导航。
diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts
index 53a405134e..7184bf5d17 100644
--- a/packages/session/session-turn-outline/src/projection.ts
+++ b/packages/session/session-turn-outline/src/projection.ts
@@ -1,14 +1,20 @@
/**
- * The `turnOutline` projection unit: a pure fold of `turn/start` boundaries
- * and first human prompts into the whole-log turn outline the chat rail
- * renders for turns outside a client's paged event window.
+ * The `turnOutline` projection unit: a pure fold of `turn/start` boundaries,
+ * first human prompts, and final assistant responses into the whole-log turn
+ * outline the chat rail renders for turns outside a client's paged event
+ * window.
*
* `turn/start` — not the prompt `user/message` — anchors each entry because
* its seq is the load-through target for a jump: the loop logs `turn/start`
* before the turn's prompt and steps, so a window paged back through that seq
- * contains the whole turn. The preview mirrors the rail's loaded-turn preview
- * (space-joined text blocks, collapsed whitespace, 160-character cap) so a
- * turn shows the same words before and after its events load.
+ * contains the whole turn. Previews mirror the rail's loaded-turn previews
+ * (space-joined text blocks, collapsed whitespace, an ellipsis when clipped)
+ * with budgets sized to the rail card's clamps — one prompt line, up to three
+ * response lines — so a turn shows the same words before and after its events
+ * load. The response commits at `turn/end` from a draft of the newest
+ * text-bearing assistant message; draft-only applies keep the `turns` array's
+ * identity, so the identity-gated change feed pushes at most three times per
+ * turn (boundary, prompt, response).
*
* @module @deepseek-ai/dsh-session-turn-outline/projection
*/
@@ -17,31 +23,40 @@ import { z } from 'zod'
import type { ZodType } from 'zod'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
-import type { TurnOutlineProjection } from './types.ts'
+import type { TurnOutlineEntry, TurnOutlineState } from './types.ts'
-/** Preview budget per entry, matching the rail's loaded-turn preview clamp. */
-const PREVIEW_LIMIT = 160
+/** Prompt budget: one rail-card line (13px over ~276px), ASCII worst case included. */
+const PROMPT_PREVIEW_LIMIT = 50
+/** Response budget: three rail-card lines (12px over ~276px). */
+const RESPONSE_PREVIEW_LIMIT = 120
-/** Space-join text blocks until the budget is met, then normalize and cap. */
-function promptPreview(content: SessionEvent<'user/message'>['data']['content']): string {
+type MessageContent = SessionEvent<'user/message'>['data']['content']
+
+/** Space-join text blocks, collapse whitespace, and cap at `limit` with a trailing ellipsis when clipped. */
+function preview(content: MessageContent, limit: number): string {
let text = ''
+ let unread = false
for (const block of content) {
if (block.type !== 'text') continue
+ if (text.length >= limit * 2) {
+ unread = true
+ break
+ }
text += text === '' ? block.text : ` ${block.text}`
- if (text.length >= PREVIEW_LIMIT) break
}
- return text.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_LIMIT)
+ const normalized = text.replace(/\s+/g, ' ').trim()
+ if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…`
+ return unread ? `${normalized}…` : normalized
}
-const turnOutlineSchema: ZodType = z.object({
- turns: z.array(z.object({
- turn: z.number().int().nonnegative(),
- seq: z.number().int().nonnegative(),
- prompt: z.string().max(PREVIEW_LIMIT),
- }).strict()),
-}).strict().superRefine((state, context) => {
+const turnOutlineEntriesSchema: ZodType = z.array(z.object({
+ turn: z.number().int().nonnegative(),
+ seq: z.number().int().nonnegative(),
+ prompt: z.string().max(PROMPT_PREVIEW_LIMIT),
+ response: z.string().max(RESPONSE_PREVIEW_LIMIT),
+}).strict()).superRefine((turns, context) => {
let previous = -1
- for (const entry of state.turns) {
+ for (const entry of turns) {
if (entry.turn <= previous) {
context.addIssue({ code: 'custom', message: 'turn outline entries must be strictly increasing by turn' })
return
@@ -50,24 +65,34 @@ const turnOutlineSchema: ZodType = z.object({
}
})
-const EMPTY_OUTLINE: TurnOutlineProjection = { turns: [] }
+const turnOutlineStateSchema: ZodType = z.object({
+ turns: turnOutlineEntriesSchema,
+ draft: z.string().max(RESPONSE_PREVIEW_LIMIT),
+}).strict()
+
+const EMPTY_OUTLINE: TurnOutlineState = { turns: [], draft: '' }
/** The `turnOutline` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
export const turnOutlineProjectionDefinition = {
key: 'turnOutline',
- stateVersion: 1,
- stateSchema: turnOutlineSchema,
+ stateVersion: 2,
+ stateSchema: turnOutlineStateSchema,
init: () => EMPTY_OUTLINE,
apply: (state, event) => {
- // Every uninteresting event returns the same reference (Object.is gates the change feed).
+ // Every uninteresting event returns the same reference (Object.is gates
+ // the drive), and draft-only changes keep `turns` identity (the raw-view
+ // identity gate then keeps the change feed quiet).
switch (event.type) {
case 'turn/start': {
const last = state.turns.at(-1)
// Order guard: a boundary that does not advance the turn number keeps
- // the outline sorted, and a retried turn's prompt lands on the
+ // the outline sorted, and a retried turn's previews land on the
// standing entry.
if (last !== undefined && event.data.turn <= last.turn) return state
- return { turns: [...state.turns, { turn: event.data.turn, seq: event.seq, prompt: '' }] }
+ return {
+ turns: [...state.turns, { turn: event.data.turn, seq: event.seq, prompt: '', response: '' }],
+ draft: '',
+ }
}
case 'user/message': {
// Only the newest turn can still be waiting for its opening human
@@ -76,16 +101,28 @@ export const turnOutlineProjectionDefinition = {
if (event.data.source.kind !== 'user') return state
const last = state.turns.at(-1)
if (last === undefined || last.prompt !== '') return state
- const prompt = promptPreview(event.data.content)
+ const prompt = preview(event.data.content, PROMPT_PREVIEW_LIMIT)
if (prompt === '') return state
- return { turns: [...state.turns.slice(0, -1), { ...last, prompt }] }
+ return { turns: [...state.turns.slice(0, -1), { ...last, prompt }], draft: state.draft }
+ }
+ case 'assistant/message': {
+ // Newest text-bearing message wins; the buffer commits at turn/end.
+ const draft = preview(event.data.message.content, RESPONSE_PREVIEW_LIMIT)
+ if (draft === '' || draft === state.draft) return state
+ return { turns: state.turns, draft }
+ }
+ case 'turn/end': {
+ if (state.draft === '') return state
+ const last = state.turns.at(-1)
+ if (last === undefined || last.response === state.draft) return { turns: state.turns, draft: '' }
+ return { turns: [...state.turns.slice(0, -1), { ...last, response: state.draft }], draft: '' }
}
default:
return state
}
},
wire: {
- viewSchema: turnOutlineSchema,
- view: state => state,
+ viewSchema: turnOutlineEntriesSchema,
+ view: state => state.turns,
},
-} satisfies ProjectionDefinition<'turnOutline', TurnOutlineProjection>
+} satisfies ProjectionDefinition<'turnOutline', TurnOutlineState>
diff --git a/packages/session/session-turn-outline/src/types.ts b/packages/session/session-turn-outline/src/types.ts
index 7746c58558..11e0b9c295 100644
--- a/packages/session/session-turn-outline/src/types.ts
+++ b/packages/session/session-turn-outline/src/types.ts
@@ -15,23 +15,33 @@ export interface TurnOutlineEntry {
readonly turn: number
/** The turn's `turn/start` event seq — paging a window back through this seq loads the whole turn. */
readonly seq: number
- /** Bounded preview of the turn's first human prompt; `''` until an eligible prompt lands. */
+ /** Bounded first-human-prompt preview (one rail-card line); `''` until an eligible prompt lands. */
readonly prompt: string
+ /** Bounded final-response preview (up to three rail-card lines); `''` until the turn ends with assistant text. */
+ readonly response: string
}
-/** Whole-log turn outline: every started turn, strictly increasing by `turn`. */
-export interface TurnOutlineProjection {
+/**
+ * Fold state: the served entries plus the open turn's response draft. The
+ * draft buffers the newest text-bearing assistant message until `turn/end`
+ * commits it, and the wire view projects only `turns` — draft-only applies
+ * keep that array's identity, so the change feed stays quiet between turn
+ * boundaries.
+ */
+export interface TurnOutlineState {
/** Started turns in ascending turn order. */
readonly turns: readonly TurnOutlineEntry[]
+ /** Newest text-bearing assistant preview of the open turn; `''` outside one. */
+ readonly draft: string
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
- /** Whole-log turn outline fold state (identical to the wire view). */
- turnOutline: TurnOutlineProjection
+ /** Whole-log turn outline fold state (entries plus the open turn's response draft). */
+ turnOutline: TurnOutlineState
}
interface SessionProjectionMap {
- /** Every started turn with its `turn/start` seq and bounded prompt preview; see {@link TurnOutlineProjection}. */
- turnOutline: TurnOutlineProjection
+ /** Every started turn with its `turn/start` seq and bounded previews, strictly increasing by turn; see {@link TurnOutlineEntry}. */
+ turnOutline: readonly TurnOutlineEntry[]
}
}
diff --git a/packages/session/session-turn-outline/tests/loader-composition.spec.ts b/packages/session/session-turn-outline/tests/loader-composition.spec.ts
index 9568cb83c6..e6be0b7f71 100644
--- a/packages/session/session-turn-outline/tests/loader-composition.spec.ts
+++ b/packages/session/session-turn-outline/tests/loader-composition.spec.ts
@@ -13,7 +13,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline'
@@ -76,8 +76,17 @@ describe('real Loader composition', () => {
content: [{ type: 'text', text: 'composed prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
+ session.append('assistant/message', {
+ turn: 1,
+ step: 1,
+ message: createAssistantMessage({
+ content: [{ type: 'text', text: 'composed answer' }],
+ source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+ }),
+ }, { surfaceOp: 'append' })
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(loaded.sessionProjections.snapshot(session).values.turnOutline)
- .toEqual({ turns: [{ turn: 1, seq: boundary, prompt: 'composed prompt' }] })
+ .toEqual([{ turn: 1, seq: boundary, prompt: 'composed prompt', response: 'composed answer' }])
})
it('keeps the function-plugin namespace free of a default export', () => {
diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts
index 80703ce778..bbee4cdc3a 100644
--- a/packages/session/session-turn-outline/tests/projection.spec.ts
+++ b/packages/session/session-turn-outline/tests/projection.spec.ts
@@ -1,21 +1,23 @@
/**
* The `turnOutline` projection unit: mounting the plugin beside the
* projection registry serves the whole-log turn outline (turn number,
- * `turn/start` seq, bounded first-prompt preview); compositions without the
- * registry are unaffected; unmounting the plugin removes the key (HMR
- * safety). Narrow fold paths with fabricated envelopes (non-human sources,
+ * `turn/start` seq, bounded prompt and final-response previews);
+ * compositions without the registry are unaffected; unmounting the plugin
+ * removes the key (HMR safety). The response buffers as a draft and commits
+ * at `turn/end`, keeping the identity-gated change feed at three pushes per
+ * turn. Narrow fold paths with fabricated envelopes (non-human sources,
* regressive turn numbers) run against the exported definition directly.
*/
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline'
import { turnOutlineProjectionDefinition } from '@deepseek-ai/dsh-session-turn-outline/src/projection.ts'
-import type { TurnOutlineProjection } from '@deepseek-ai/dsh-session-turn-outline/types'
+import type { TurnOutlineEntry, TurnOutlineState } from '@deepseek-ai/dsh-session-turn-outline/types'
async function harness(withOutlinePlugin: boolean): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
@@ -33,54 +35,78 @@ function appendPrompt(session: Session, text: string): number {
}), { surfaceOp: 'append' }).seq
}
-function outlineOf(ctx: Context, session: Session): TurnOutlineProjection {
- return ctx.sessionProjections.snapshot(session).values.turnOutline as TurnOutlineProjection
+/** Append one assembled assistant message with a single text block. */
+function appendAssistant(session: Session, turn: number, step: number, text: string): void {
+ session.append('assistant/message', {
+ turn,
+ step,
+ message: createAssistantMessage({
+ content: [{ type: 'text', text }],
+ source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+ }),
+ }, { surfaceOp: 'append' })
+}
+
+function endTurn(session: Session, turn: number): number {
+ return session.append('turn/end', { turn, reason: { kind: 'completed' } }).seq
+}
+
+function outlineOf(ctx: Context, session: Session): readonly TurnOutlineEntry[] {
+ return ctx.sessionProjections.snapshot(session).values.turnOutline as readonly TurnOutlineEntry[]
}
describe('turn outline projection unit', () => {
it('serves an empty outline before any turn starts', async () => {
const { ctx, session } = await harness(true)
- expect(outlineOf(ctx, session)).toEqual({ turns: [] })
+ expect(outlineOf(ctx, session)).toEqual([])
expect(ctx.sessionProjections.checkpoint(session).turnOutline)
- .toEqual({ ver: 1, seq: -1, val: { turns: [] } })
+ .toEqual({ ver: 2, seq: -1, val: { turns: [], draft: '' } })
})
- it('folds each started turn with its boundary seq and first human prompt only', async () => {
+ it('folds each turn with its boundary seq, first prompt, and turn-end response', async () => {
const { ctx, session } = await harness(true)
const firstBoundary = session.append('turn/start', { turn: 1 }).seq
appendPrompt(session, 'hello world')
appendPrompt(session, 'a later steer must not replace the prompt')
+ appendAssistant(session, 1, 1, 'first draft answer')
+ appendAssistant(session, 1, 2, 'final answer of turn one')
+ endTurn(session, 1)
const secondBoundary = session.append('turn/start', { turn: 2 }).seq
appendPrompt(session, 'second prompt')
- expect(outlineOf(ctx, session)).toEqual({
- turns: [
- { turn: 1, seq: firstBoundary, prompt: 'hello world' },
- { turn: 2, seq: secondBoundary, prompt: 'second prompt' },
- ],
- })
+ expect(outlineOf(ctx, session)).toEqual([
+ { turn: 1, seq: firstBoundary, prompt: 'hello world', response: 'final answer of turn one' },
+ { turn: 2, seq: secondBoundary, prompt: 'second prompt', response: '' },
+ ])
})
- it('keeps an empty preview for a turn whose prompt never lands', async () => {
+ it('keeps the response empty while its turn is still open (draft only commits at turn/end)', async () => {
const { ctx, session } = await harness(true)
- const boundary = session.append('turn/start', { turn: 1 }).seq
- session.append('step/start', { turn: 1, step: 1 })
- expect(outlineOf(ctx, session)).toEqual({ turns: [{ turn: 1, seq: boundary, prompt: '' }] })
+ session.append('turn/start', { turn: 1 })
+ appendPrompt(session, 'prompt')
+ appendAssistant(session, 1, 1, 'streamed but unsettled')
+ expect(outlineOf(ctx, session)[0]?.response).toBe('')
+ expect(ctx.sessionProjections.stateOf(session, 'turnOutline')?.draft).toBe('streamed but unsettled')
+ endTurn(session, 1)
+ expect(outlineOf(ctx, session)[0]?.response).toBe('streamed but unsettled')
})
- it('collapses whitespace, joins text blocks, and caps the preview at 160 characters', async () => {
+ it('collapses whitespace and caps previews at their card budgets with an ellipsis', async () => {
const { ctx, session } = await harness(true)
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [
- { type: 'text', text: ` first\n\nline\t${'x'.repeat(200)}` },
+ { type: 'text', text: ` spaced\n\nprompt\t${'p'.repeat(80)}` },
{ type: 'text', text: 'never reached past the budget' },
],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
- const preview = outlineOf(ctx, session).turns[0]?.prompt
- expect(preview).toBeDefined()
- expect(preview).toMatch(/^first line x+$/)
- expect(preview).toHaveLength(160)
+ appendAssistant(session, 1, 1, `answer ${'r'.repeat(200)}`)
+ endTurn(session, 1)
+ const entry = outlineOf(ctx, session)[0]
+ expect(entry?.prompt).toMatch(/^spaced prompt p+…$/)
+ expect(entry?.prompt).toHaveLength(50)
+ expect(entry?.response).toMatch(/^answer r+…$/)
+ expect(entry?.response).toHaveLength(120)
})
it('ignores non-human user/message sources and pre-turn prompts', async () => {
@@ -91,30 +117,32 @@ describe('turn outline projection unit', () => {
content: [{ type: 'text', text: 'injected context' }],
source: { kind: 'plugin', plugin: 'test-injector', form: 'relay' },
}), { surfaceOp: 'append' })
- expect(outlineOf(ctx, session)).toEqual({
- turns: [{ turn: 1, seq: 1, prompt: '' }],
- })
+ expect(outlineOf(ctx, session)).toEqual([
+ { turn: 1, seq: 1, prompt: '', response: '' },
+ ])
})
- it('notifies the change feed only when the outline actually moves', async () => {
+ it('pushes at most three times per turn: boundary, prompt, and settled response', async () => {
const { ctx, session } = await harness(true)
- const changes: { key: string; seq: number }[] = []
- ctx.sessionProjections.onChanged((_session, key, _value, seq) => {
- if (key === 'turnOutline') changes.push({ key, seq })
+ const changes: { seq: number; last: TurnOutlineEntry | undefined }[] = []
+ ctx.sessionProjections.onChanged((_session, key, value, seq) => {
+ if (key !== 'turnOutline') return
+ changes.push({ seq, last: (value as readonly TurnOutlineEntry[]).at(-1) })
})
const boundarySeq = session.append('turn/start', { turn: 1 }).seq
session.append('step/start', { turn: 1, step: 1 })
const promptSeq = appendPrompt(session, 'hello')
appendPrompt(session, 'second human message in the same turn')
- session.append('step/end', { turn: 1, step: 1 })
- expect(changes).toEqual([
- { key: 'turnOutline', seq: boundarySeq },
- { key: 'turnOutline', seq: promptSeq },
- ])
+ appendAssistant(session, 1, 1, 'draft one')
+ appendAssistant(session, 1, 2, 'draft two')
+ session.append('step/end', { turn: 1, step: 2 })
+ const endSeq = endTurn(session, 1)
+ expect(changes.map(change => change.seq)).toEqual([boundarySeq, promptSeq, endSeq])
+ expect(changes.at(-1)?.last?.response).toBe('draft two')
})
it('skips a boundary that does not advance the turn number (fabricated envelope)', () => {
- const state: TurnOutlineProjection = { turns: [{ turn: 2, seq: 5, prompt: 'kept' }] }
+ const state: TurnOutlineState = { turns: [{ turn: 2, seq: 5, prompt: 'kept', response: '' }], draft: '' }
const regressive = {
type: 'turn/start',
seq: 9,
@@ -129,7 +157,7 @@ describe('turn outline projection unit', () => {
session.append('turn/start', { turn: 1 })
appendPrompt(session, 'pre-mount prompt')
await ctx.plugin(SessionTurnOutlinePlugin)
- expect(outlineOf(ctx, session).turns).toEqual([{ turn: 1, seq: 0, prompt: 'pre-mount prompt' }])
+ expect(outlineOf(ctx, session)).toEqual([{ turn: 1, seq: 0, prompt: 'pre-mount prompt', response: '' }])
})
it('has no key without the plugin and drops it when the plugin unloads (HMR safety)', async () => {
@@ -151,14 +179,26 @@ describe('turn outline projection unit', () => {
...checkpoint,
turnOutline: {
...row!,
- val: { turns: [{ turn: 2, seq: 1, prompt: '' }, { turn: 2, seq: 4, prompt: '' }] },
+ val: {
+ turns: [
+ { turn: 2, seq: 1, prompt: '', response: '' },
+ { turn: 2, seq: 4, prompt: '', response: '' },
+ ],
+ draft: '',
+ },
},
}, [], 0, session.header)).toThrow(/strictly increasing/)
expect(() => ctx.sessionProjections.restore({
...checkpoint,
turnOutline: {
...row!,
- val: { turns: [{ turn: 1, seq: 1, prompt: 'ok' }, { turn: 2, seq: 4, prompt: '' }] },
+ val: {
+ turns: [
+ { turn: 1, seq: 1, prompt: 'ok', response: 'done' },
+ { turn: 2, seq: 4, prompt: '', response: '' },
+ ],
+ draft: '',
+ },
},
}, [], 0, session.header)).not.toThrow()
})
From 7c58a95ebb86430a90d3555b0abc7f7e47d307b1 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 14:39:39 +0800
Subject: [PATCH 13/26] test(session-turn-outline): cover fold edges and
deflake settle assertion
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Edge-branch coverage for the preview reading bound, repeated and empty
drafts, draftless turn ends, orphan-draft clearing, and same-response
recommits (the CI per-file gate exercises them); the jump-settle spec
now asserts only the busy lifecycle after settlement — jsdom's zero
geometry made the rAF active-turn resync timing-dependent under
coverage instrumentation, and the landing position contract lives in
the browser e2e.
---
.../ui-chat/tests/chat-view.client.spec.tsx | 5 ++-
.../tests/projection.spec.ts | 44 +++++++++++++++++++
2 files changed, 48 insertions(+), 1 deletion(-)
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 14489a3d37..6755af45ba 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -720,8 +720,11 @@ describe('ChatView', () => {
// runs the final landing correction after the load-earlier button leaves.
expect(first.getAttribute('aria-busy')).toBe('true')
await act(async () => { releaseJump?.() })
+ // Only the busy lifecycle is asserted after settlement: jsdom's zero
+ // geometry makes the rAF active-turn resync read "at bottom" and hand the
+ // mark to the last turn, so aria-current here is timing-dependent under
+ // instrumentation; the landing position contract lives in the browser e2e.
expect(first.getAttribute('aria-busy')).toBeNull()
- expect(first.getAttribute('aria-current')).toBe('true')
})
it('hands a windowless tool result to the Tool seat with an empty tool name', () => {
diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts
index bbee4cdc3a..3749fe554c 100644
--- a/packages/session/session-turn-outline/tests/projection.spec.ts
+++ b/packages/session/session-turn-outline/tests/projection.spec.ts
@@ -141,6 +141,50 @@ describe('turn outline projection unit', () => {
expect(changes.at(-1)?.last?.response).toBe('draft two')
})
+ it('keeps quiet on a draftless turn end and an empty in-turn prompt', async () => {
+ const { ctx, session } = await harness(true)
+ session.append('turn/start', { turn: 1 })
+ // Whitespace-only prompt text normalizes to nothing: the entry stays unlabeled.
+ appendPrompt(session, ' \t ')
+ endTurn(session, 1)
+ expect(outlineOf(ctx, session)).toEqual([{ turn: 1, seq: 0, prompt: '', response: '' }])
+ })
+
+ it('bounds preview reading and keeps repeated or empty drafts quiet (fabricated envelopes)', () => {
+ const def = turnOutlineProjectionDefinition
+ const assistant = (blocks: readonly unknown[]): SessionEvent => ({
+ type: 'assistant/message',
+ seq: 9,
+ time: 0,
+ data: { message: { content: blocks } },
+ }) as unknown as SessionEvent
+ const base: TurnOutlineState = { turns: [{ turn: 1, seq: 0, prompt: 'p', response: '' }], draft: '' }
+ // Non-text blocks are skipped; whitespace-heavy short blocks cross the raw
+ // reading bound early, so the collapsed (short) draft still marks the
+ // unread remainder with an ellipsis.
+ const airy = Array.from({ length: 40 }, (_, index) => ({ type: 'text', text: `w${String(index)}${' '.repeat(20)}` }))
+ const buffered = def.apply(base, assistant([{ type: 'tool-call' }, ...airy]))
+ expect(buffered.draft.startsWith('w0 w1 ')).toBe(true)
+ expect(buffered.draft.endsWith('…')).toBe(true)
+ expect(buffered.draft.length).toBeLessThan(120)
+ // The same draft again, or a text-free message, changes nothing.
+ expect(def.apply(buffered, assistant([{ type: 'tool-call' }, ...airy]))).toBe(buffered)
+ expect(def.apply(buffered, assistant([{ type: 'text', text: ' ' }]))).toBe(buffered)
+ // A draft with no entry to commit into clears itself at the boundary…
+ const end = {
+ type: 'turn/end',
+ seq: 11,
+ time: 0,
+ data: { turn: 1, reason: { kind: 'completed' } },
+ } as unknown as SessionEvent
+ expect(def.apply({ turns: [], draft: 'orphan' }, end)).toEqual({ turns: [], draft: '' })
+ // …and a re-settled identical response keeps the entries' identity.
+ const settled: TurnOutlineState = { turns: [{ turn: 1, seq: 0, prompt: 'p', response: 'done' }], draft: 'done' }
+ const recommitted = def.apply(settled, end)
+ expect(recommitted.turns).toBe(settled.turns)
+ expect(recommitted.draft).toBe('')
+ })
+
it('skips a boundary that does not advance the turn number (fabricated envelope)', () => {
const state: TurnOutlineState = { turns: [{ turn: 2, seq: 5, prompt: 'kept', response: '' }], draft: '' }
const regressive = {
From db5417ff6fa0c528584f3b00401f205969ea0a3d Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 18:09:02 +0800
Subject: [PATCH 14/26] fix(session-controller): keep refused jumps from
parking a stale target
loadThrough now assigns its low-water target only when it owns the loop
(retargeting stays inside the running-jump branch): a call refused while
a plain load-earlier pull holds the pager no longer leaves jumpTargetSeq
behind to drag a later jump all the way to the head. The loop also
carries the doOpen stale-pass guard so a mid-flight resync stops it
instead of paging the new stream generation toward the old target.
---
.../src/client/sessions/session.ts | 17 +++++--
.../tests/session.client.spec.ts | 51 +++++++++++++++++++
2 files changed, 65 insertions(+), 3 deletions(-)
diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts
index 294f33f3aa..6e33412cf9 100644
--- a/packages/api/session-controller/src/client/sessions/session.ts
+++ b/packages/api/session-controller/src/client/sessions/session.ts
@@ -379,16 +379,27 @@ export class Session implements SessionFace {
/** Jump loader: page backwards until the window covers seq (see ISession.loadThrough). */
loadThrough(seq: number): Promise {
if (this.openState !== 'open' || !this.hasMore || this.baseSeq <= seq) return Promise.resolve()
- this.jumpTargetSeq = Math.min(this.jumpTargetSeq ?? seq, seq)
- if (this.jumpPromise !== null) return this.jumpPromise
+ if (this.jumpPromise !== null) {
+ // Retarget the running loop to the lowest requested seq.
+ this.jumpTargetSeq = Math.min(this.jumpTargetSeq ?? seq, seq)
+ return this.jumpPromise
+ }
// A plain single-page pull owns the busy flag; the jump does not queue
- // behind it (the caller may retry once it settles).
+ // behind it (the caller retries once it settles) and must leave no
+ // target behind — only the loop's finally clears that field, and no
+ // loop starts here.
if (this.loadingOlder) return Promise.resolve()
+ this.jumpTargetSeq = seq
this.loadingOlder = true
this.notifier.markDirty()
+ // Stale-pass guard (the doOpen pattern): a resync mid-loop replaces the
+ // stream generation; this pass then stops instead of paging the new
+ // generation toward its old target.
+ const generation = this.openGeneration
this.jumpPromise = (async () => {
try {
while (this.hasMore && this.jumpTargetSeq !== null && this.baseSeq > this.jumpTargetSeq) {
+ if (generation !== this.openGeneration) return
const events = this.events
if (events === undefined) return
const before = this.baseSeq
diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts
index 2bee7d33b4..899a3f7745 100644
--- a/packages/api/session-controller/tests/session.client.spec.ts
+++ b/packages/api/session-controller/tests/session.client.spec.ts
@@ -316,6 +316,57 @@ describe('paging', () => {
expect(api.callsOf('session.history')).toHaveLength(2)
})
+ it('loadThrough refused by a busy pager leaves no target behind for later jumps', async () => {
+ const middle = plainTurn(6, 1, 'c', 'd')
+ const { api, session } = makeSession()
+ api.onHistory = () => histResponse(plainTurn(12, 2, 'e', 'f'), true)
+ await session.open()
+
+ // A plain single-page pull holds the busy flag while the jump is refused.
+ const gate = deferred>>()
+ api.onHistory = () => gate.promise
+ const older = session.loadOlder()
+ await session.loadThrough(0) // refused: must not park seq 0 anywhere
+ gate.resolve(ok(historyValue(middle, true)))
+ await older
+
+ // A later jump to a nearer seq pages exactly to it — a leaked 0 target
+ // would keep pulling three-event pages all the way to the head.
+ api.onHistory = (payload) => {
+ const start = ((payload as { beforeSeq?: number }).beforeSeq ?? 0) - 3
+ return histResponse(
+ [ev.user(start, `u${String(start)}`), ev.user(start + 1, `u${String(start + 1)}`), ev.user(start + 2, `u${String(start + 2)}`)],
+ start > 0,
+ )
+ }
+ await session.loadThrough(4)
+ // Covered at seq 3 (≤ 4) after one page; a leaked 0 target would add a
+ // third call at beforeSeq 3 and pull the head to 0.
+ expect(api.callsOf('session.history').map(call => (call as { beforeSeq?: number }).beforeSeq))
+ .toEqual([12, 6])
+ expect(eventSeqs(session)[0]).toBe(3)
+ })
+
+ it('loadThrough stops paging when the event stream generation moves mid-loop', async () => {
+ const { api, session } = makeSession()
+ api.onHistory = () => histResponse(plainTurn(12, 2, 'x', 'y'), true)
+ await session.open()
+
+ const gate = deferred>>()
+ api.onHistory = () => gate.promise
+ const jump = session.loadThrough(0)
+ // The address is rebuilt while the first page is in flight.
+ api.onHistory = () => histResponse(plainTurn(12, 2, 'x', 'y'), true)
+ const rebuilt = session.resync()
+ gate.resolve(ok(historyValue(plainTurn(6, 1, 'c', 'd'), true)))
+ await jump
+ await rebuilt
+ // The stale loop must not page the new generation toward its old target:
+ // history calls are the gated page and the resync tail only.
+ expect(api.callsOf('session.history')).toHaveLength(1)
+ expect(session.getSnapshot().loadingOlder).toBe(false)
+ })
+
it('loadThrough stops on a page that makes no progress instead of looping', async () => {
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
From 39b90961c73f2f884a031b08f0c37c57ae354c5b Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 21:39:36 +0800
Subject: [PATCH 15/26] fix(ui-chat): hold jumps while a plain pull owns the
pager
A jump clicked while the Load-earlier pull was in flight fell through the
settle effect's nearest-turn fallback and landed on the wrong row. The
effect now keeps the pending jump (busy pulse stays) while loadingOlder is
true and a retry tick re-issues loadThrough when the pull settles.
Also repins the long-interactions e2e rail block to the outline-rail
semantics (fixed mark pitch, load-and-jump labels) and covers the
loadThrough forwarding paths in apply-inject and the client-runtime stub.
---
apps/web/tests/chat-long-interactions.e2e.ts | 57 ++++++++++---------
.../ui-chat/src/client/chat/ChatView.tsx | 35 ++++++++----
.../tests/apply-inject.client.spec.tsx | 4 ++
.../ui-chat/tests/chat-view.client.spec.tsx | 23 ++++++++
.../tests/runtime.client.spec.tsx | 1 +
5 files changed, 81 insertions(+), 39 deletions(-)
diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts
index 5e3c02a436..55829bd4d5 100644
--- a/apps/web/tests/chat-long-interactions.e2e.ts
+++ b/apps/web/tests/chat-long-interactions.e2e.ts
@@ -195,43 +195,44 @@ describe('web e2e: long Chat interaction contract', () => {
const turnNavigation = page.getByRole('navigation', { name: 'Turn navigation' })
await turnNavigation.waitFor({ state: 'visible', timeout: 15_000 })
- const initialTurnButtons = turnNavigation.getByRole('button')
- const initialTurnCount = await initialTurnButtons.count()
- expect(initialTurnCount).toBeGreaterThan(1)
- expect(await initialTurnButtons.last().getAttribute('aria-current')).toBe('true')
- const firstTurnButton = initialTurnButtons.first()
- const firstTurnLabel = await firstTurnButton.getAttribute('aria-label')
- if (firstTurnLabel === null) throw new Error('first Turn navigation mark has no accessible label')
- const firstTurn = Number(firstTurnLabel.match(/^Jump to turn (\d+)$/)?.[1])
- expect(Number.isSafeInteger(firstTurn)).toBe(true)
+ // The whole-log outline offers every fixture turn before any paging, with
+ // the live tail mark current.
+ const marks = turnNavigation.getByRole('button')
+ await expect.poll(() => marks.count(), { timeout: 15_000 }).toBe(FIXTURE_TURNS)
+ expect(await marks.last().getAttribute('aria-current')).toBe('true')
+ // The oldest turn is an unloaded mark whose outline preview already
+ // carries both the prompt and the settled response.
+ const firstTurnButton = turnNavigation
+ .getByRole('button', { name: 'Load and jump to turn 1', exact: true })
await firstTurnButton.focus()
const preview = page.getByRole('tooltip')
await preview.waitFor({ state: 'visible', timeout: 5_000 })
- // The first loaded Turn may begin mid-Turn at a page boundary. Its mark is
- // still useful with the loaded response and gains the prompt after prepend.
- expect(await preview.textContent()).toContain(`Turn ${String(firstTurn)}`)
- expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(firstTurn))
+ expect(await preview.textContent()).toContain(FIXTURE.markers.user(1))
+ expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(1))
const firstTurnPosition = await firstTurnButton.evaluate(button => (
- button.parentElement?.style.getPropertyValue('--turn-position') ?? ''
+ button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? ''
))
- expect(firstTurnPosition).toBe('0%')
+ expect(firstTurnPosition).toBe('0px')
const loadEarlier = page.getByRole('button', { name: 'Load earlier', exact: true })
+ const loadedMarks = turnNavigation.getByRole('button', { name: /^Jump to turn / })
+ const loadedBefore = await loadedMarks.count()
await loadEarlier.click()
- await expect.poll(() => turnNavigation.getByRole('button').count(), { timeout: 15_000 })
- .toBeGreaterThan(initialTurnCount)
- const stableFirstTurnButton = turnNavigation.getByRole('button', { name: firstTurnLabel })
- expect(await stableFirstTurnButton.evaluate(button => (
- button.parentElement?.style.getPropertyValue('--turn-position') ?? ''
- ))).not.toBe(firstTurnPosition)
- await stableFirstTurnButton.focus()
- await expect.poll(() => preview.textContent(), { timeout: 5_000 })
- .toContain(FIXTURE.markers.user(firstTurn))
- expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(firstTurn))
- await stableFirstTurnButton.press('Enter')
- await expect.poll(() => stableFirstTurnButton.getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
+ // Paging converts marks to their loaded form without moving the
+ // fixed-pitch ladder.
+ await expect.poll(() => loadedMarks.count(), { timeout: 15_000 }).toBeGreaterThan(loadedBefore)
+ expect(await firstTurnButton.evaluate(button => (
+ button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? ''
+ ))).toBe(firstTurnPosition)
+ // Activating the still-unloaded oldest mark pages the rest in and lands
+ // on the turn's own row.
+ await firstTurnButton.focus()
+ await firstTurnButton.press('Enter')
+ const firstLoaded = turnNavigation.getByRole('button', { name: 'Jump to turn 1', exact: true })
+ await firstLoaded.waitFor({ timeout: 60_000 })
+ await expect.poll(() => firstLoaded.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true')
await expect.poll(
- () => page.locator(`[data-chat-turn="${String(firstTurn)}"][data-chat-flow-kind="user"]`).count(),
+ () => page.locator('[data-chat-turn="1"][data-chat-flow-kind="user"]').count(),
{ timeout: 5_000 },
).toBe(1)
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index 0b422d0281..42aada1017 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -616,10 +616,12 @@ export function ChatView({
}, [loadingOlder])
// Jump settlement: every loadThrough completion bumps the tick after its
- // last page's commit. A still-pending jump is either realized now, repaged
- // once per head movement (its own paging can be refused while a plain pull
- // holds the busy flag), or landed on the nearest rendered Turn at or after
- // the target (failure, exhausted history, or a Turn with no visible row).
+ // last page's commit, and a plain pull's loadingOlder flip re-settles a
+ // jump it made wait. A still-pending jump is realized now, held while a
+ // plain load-earlier pull owns the pager (its completion retries below),
+ // repaged once per head movement, or landed on the nearest rendered Turn
+ // at or after the target (failure, exhausted history, or a Turn with no
+ // visible row).
useEffect(() => {
const pending = pendingJumpRef.current
const local = listRef.current
@@ -629,14 +631,19 @@ export function ChatView({
// commit, so the target row cannot drift once the jump clears.
if (realizePendingJump(local, el, true)) return
const uncovered = firstSeq === null || firstSeq > pending.seq
- if (uncovered && hasMore && !loadingOlder && jumpRepageHeadRef.current !== firstSeq) {
- jumpRepageHeadRef.current = firstSeq
- const held = pagingAnchor(local, el)
- if (held !== null && held.dataset.chatAnchorKey !== undefined) {
- anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) }
+ if (uncovered && hasMore) {
+ // A plain pull owns the pager right now: hold the jump (busy stays)
+ // instead of degrading to a wrong landing.
+ if (loadingOlder) return
+ if (jumpRepageHeadRef.current !== firstSeq) {
+ jumpRepageHeadRef.current = firstSeq
+ const held = pagingAnchor(local, el)
+ if (held !== null && held.dataset.chatAnchorKey !== undefined) {
+ anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) }
+ }
+ void loadThrough(pending.seq).finally(() => { setJumpSettleTick(tick => tick + 1) })
+ return
}
- void loadThrough(pending.seq).finally(() => { setJumpSettleTick(tick => tick + 1) })
- return
}
for (const row of local.querySelectorAll('[data-chat-turn]:not([hidden])')) {
const turn = Number(row.dataset.chatTurn)
@@ -649,6 +656,12 @@ export function ChatView({
// Snapshot values are read at settle time; the completion tick is the trigger.
}, [jumpSettleTick])
+ // A jump held while a plain pull owned the pager waits in the effect
+ // above; the pull's completion is its retry signal.
+ useEffect(() => {
+ if (!loadingOlder && pendingJumpRef.current !== null) setJumpSettleTick(tick => tick + 1)
+ }, [loadingOlder])
+
const loadOlderAnchored = (): void => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
index 052c2b3bf8..98b477c57f 100644
--- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
+++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
@@ -35,6 +35,7 @@ type ChatActions = ChatInstance['actions']
function sessionFakeFor() {
return {
loadOlder: vi.fn(() => Promise.resolve()),
+ loadThrough: vi.fn(() => Promise.resolve()),
readAttachment: vi.fn(() => Promise.resolve({
ok: true,
value: { attachment: ATTACHMENT, data: Uint8Array.of(1) },
@@ -92,6 +93,9 @@ describe('Chat inject API', () => {
injected.loadOlder()
expect(b.session.loadOlder).toHaveBeenCalledOnce()
+ void injected.loadThrough(42)
+ expect(b.session.loadThrough).toHaveBeenCalledWith(42)
+
injected.forkAt(17)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 6755af45ba..9ebacea1de 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -657,6 +657,29 @@ describe('ChatView', () => {
await act(async () => { releaseJump?.() })
})
+ it('holds a jump issued while a plain pull owns the pager and resumes it when the pull settles', async () => {
+ const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
+ const h = makeHarness({ nodes: later }, { hasMore: true, loadingOlder: true })
+ h.setOutline([
+ { turn: 1, seq: 0, prompt: 'first prompt', response: '' },
+ { turn: 3, seq: 8, prompt: 'third prompt', response: '' },
+ ])
+ const view = render( )
+ const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
+ fireEvent.click(first)
+ // The session-side guard refuses the busy-pager jump instantly, yet the
+ // mark stays busy instead of degrading to the nearest loaded turn.
+ await act(async () => {})
+ expect(h.loadThrough.mock.calls).toEqual([[0]])
+ expect(first.getAttribute('aria-busy')).toBe('true')
+
+ // The plain pull settles: the flip re-settles the jump, which repages.
+ act(() => { h.setSession({ loadingOlder: false }) })
+ await act(async () => {})
+ expect(h.loadThrough.mock.calls).toEqual([[0], [0]])
+ expect(first.getAttribute('aria-busy')).toBeNull()
+ })
+
it('scrolls the fixed-pitch rail inside its frame with gradient fades at the scrollable ends', () => {
const h = makeHarness(
{ nodes: [userInTurn(8, 'latest prompt', 60), assistant(9, 'latest response', 60)] },
diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx
index 5216f778c2..d16175f9b5 100644
--- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx
+++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx
@@ -432,6 +432,7 @@ describe('fixture session face', () => {
expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
expect(() => bare.command()).toThrow(/command is not stubbed/)
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
+ expect(() => bare.loadThrough()).toThrow(/loadThrough is not stubbed/)
expect(() => bare.rename()).toThrow(/rename is not stubbed/)
const submission = bare.beginSubmission()
expect(submission.requestId).toBe('test-submission-1')
From 8322f804cb179627e5cb153d6d8a6dc18fb9cbd3 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 21:56:19 +0800
Subject: [PATCH 16/26] fix(session-projection): advance the view dedup
baseline on every change
The change feed stamped lastView only when a listener was subscribed, so a
value change during a listener-free window (HMR swap) froze the baseline
and a later transition back to the old value was silently deduplicated.
The baseline now advances on every changed state, heard or not; broadcast
still only happens with listeners. Docs, catalog, and the feature note
follow the corrected semantics.
---
...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +--
.../2026-08-30-web-turn-rail-outline-jump.md | 2 +-
...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +-
docs/subsystems/session-projection.i18n.yaml | 4 +--
docs/subsystems/session-projection.md | 10 +++---
docs/subsystems/session-projection.zh.md | 10 +++---
.../extensions/tool-cordis/src/api-catalog.ts | 2 +-
.../session-projection/README.i18n.yaml | 4 +--
packages/session/session-projection/README.md | 2 +-
.../session/session-projection/README.zh.md | 2 +-
.../session/session-projection/src/index.ts | 30 +++++++++-------
.../session-projection/tests/registry.spec.ts | 34 +++++++++++++++++++
12 files changed, 73 insertions(+), 33 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
index cd2ef7bb8f..dc48dfa503 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
-2026-08-30-web-turn-rail-outline-jump.md: 7fa2ba4ee2a09a78b277b238d215413e60dc9a54
-2026-08-30-web-turn-rail-outline-jump.zh.md: 544c43715217e1beda925eb6919cfbd3d5df3907
+2026-08-30-web-turn-rail-outline-jump.md: b2803aebf54c342c08da92131db7617b28ad1620
+2026-08-30-web-turn-rail-outline-jump.zh.md: 547e708a76e2205f7e2a1c83c9b3330975136393
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
index 7fa2ba4ee2..b2803aebf5 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
@@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone.
**Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn.
-**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output against the last delivered one and stays quiet when `Object.is`-identical. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
+**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output against the unit's previous projection and stays quiet when `Object.is`-identical; the baseline advances on every change, heard or not, so a later listener generation still sees every value transition. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
**Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
index 544c437152..547e708a76 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
@@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口
**数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。
-**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把原始 `view` 输出与上一次交付的值比较,`Object.is` 相同即保持安静。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
+**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把原始 `view` 输出与该单元上一次投影值比较,`Object.is` 相同即保持安静;基线随每次变更前进、有无监听者都一样,后来的监听器换代仍能看到每次值转变。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
**分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml
index 77c585ceca..63a040c196 100644
--- a/docs/subsystems/session-projection.i18n.yaml
+++ b/docs/subsystems/session-projection.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: ee6b301bde49986fb24e75a8de7fec7b7fc41688
-session-projection.zh.md: 36b6c874b440165ec56dddc938aff030fcc69b49
+session-projection.md: b7709b8d6254166afff08688c463161998731864
+session-projection.zh.md: 71da679e0cf50e6527889fd209a43291371ce313
diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md
index ee6b301bde..b7709b8d62 100644
--- a/docs/subsystems/session-projection.md
+++ b/docs/subsystems/session-projection.md
@@ -86,9 +86,9 @@ interface ProjectionSnapshot {
* Change-feed listener: one unit's served value changed for one session.
* `value` is the schema-validated `view` output; `seq` is the unit's
* watermark at emission (the seq of the event that caused the change). A
- * changed state whose raw `view` output is `Object.is`-identical to the last
- * delivered one does not fire, so a unit can buffer working fields in state
- * behind an identity-stable projection.
+ * changed state whose raw `view` output is `Object.is`-identical to the
+ * unit's previous projection does not fire, so a unit can buffer working
+ * fields in state behind an identity-stable projection.
*/
type ProjectionChangeListener = (
session: Session,
@@ -98,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the last value the feed delivered, so a unit can buffer working fields in state behind an identity-stable projection; `apply` must return the same reference when its state did not change.
+`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the unit's previous projection (the baseline advances with every change, heard or not), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
## The registry: `ctx.sessionProjections`
@@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the last delivered one (identity-stable projections stay quiet). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; the baseline advances with every change, heard or not). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md
index 36b6c874b4..71da679e0c 100644
--- a/docs/subsystems/session-projection.zh.md
+++ b/docs/subsystems/session-projection.zh.md
@@ -86,9 +86,9 @@ interface ProjectionSnapshot {
* Change-feed listener: one unit's served value changed for one session.
* `value` is the schema-validated `view` output; `seq` is the unit's
* watermark at emission (the seq of the event that caused the change). A
- * changed state whose raw `view` output is `Object.is`-identical to the last
- * delivered one does not fire, so a unit can buffer working fields in state
- * behind an identity-stable projection.
+ * changed state whose raw `view` output is `Object.is`-identical to the
+ * unit's previous projection does not fire, so a unit can buffer working
+ * fields in state behind an identity-stable projection.
*/
type ProjectionChangeListener = (
session: Session,
@@ -98,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与变更流上一次交付的值 `Object.is` 相同,因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静;状态未变时,`apply` 必须返回同一引用。
+`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与该单元上一次投影值 `Object.is` 相同(基线随每次变更前进,无论有无监听者),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
## 注册表:`ctx.sessionProjections`
@@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the last delivered one (identity-stable projections stay quiet). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; the baseline advances with every change, heard or not). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index f1a53af785..a55f3bf514 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'sessionProjections',
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
- description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the last delivered one (identity-stable projections stay quiet). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
+ description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; the baseline advances with every change, heard or not). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
methods: [
{
signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void',
diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml
index c197c6da85..7a6652ec0e 100644
--- a/packages/session/session-projection/README.i18n.yaml
+++ b/packages/session/session-projection/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
-README.md: 0268f05ef49c27082478e19941e5b3888cbce46e
-README.zh.md: 286aeebf0ffdf5b5d1c4eeadc00d9df64c26daf7
+README.md: c498d6f163d704467f035f07e3da1ce1d484a163
+README.zh.md: eff47a64f2547e56642527cffb05e219d18397bf
diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md
index 0268f05ef4..c498d6f163 100644
--- a/packages/session/session-projection/README.md
+++ b/packages/session/session-projection/README.md
@@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable
### Design concept
-The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the last delivered one stays quiet (so a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
+The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the unit's previous projection stays quiet, with the baseline advancing on every change, heard or not (so a unit can buffer working fields behind an identity-stable projection without silencing later listener generations). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
### Source map
diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md
index 286aeebf0f..eff47a64f2 100644
--- a/packages/session/session-projection/README.zh.md
+++ b/packages/session/session-projection/README.zh.md
@@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
### 设计理念
-本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上次交付相同的单元同样保持安静(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
+本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与该单元上一次投影值相同的同样保持安静,且基线随每次变更前进、有无监听者都一样(单元因此可以把工作字段缓冲在身份稳定的投影之后,而不会静默掉后来监听者的值变化)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
### 源码地图
diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts
index f48df406ec..3e3879df23 100644
--- a/packages/session/session-projection/src/index.ts
+++ b/packages/session/session-projection/src/index.ts
@@ -86,9 +86,9 @@ export interface ProjectionDefinition<
* Change-feed listener: one unit's served value changed for one session.
* `value` is the schema-validated `view` output; `seq` is the unit's
* watermark at emission (the seq of the event that caused the change). A
- * changed state whose raw `view` output is `Object.is`-identical to the last
- * delivered one does not fire, so a unit can buffer working fields in state
- * behind an identity-stable projection.
+ * changed state whose raw `view` output is `Object.is`-identical to the
+ * unit's previous projection does not fire, so a unit can buffer working
+ * fields in state behind an identity-stable projection.
*/
export type ProjectionChangeListener = (
session: Session,
@@ -145,10 +145,11 @@ interface UnitCell {
/** Seq of the last event passed through `apply` (regardless of change). */
observedSeq: number
/**
- * Raw (pre-validation) `view` output the change feed last delivered, when
- * it has delivered one. A changed state whose raw view is `Object.is` to
- * this stays quiet, so a unit can buffer working fields in state by
- * keeping its wire projection identity-stable.
+ * Raw (pre-validation) `view` output of the last changed state, stamped
+ * whether or not a listener heard it. A changed state whose raw view is
+ * `Object.is` to this stays quiet, so a unit can buffer working fields in
+ * state by keeping its wire projection identity-stable — and a listener
+ * subscribing later still sees every value transition since this baseline.
*/
lastView?: { raw: unknown }
}
@@ -177,7 +178,8 @@ interface Registration {
* every registered unit's `apply` (eager drive), and a changed state
* reference in a client-visible unit notifies the change feed with the
* schema-validated view — unless the raw view output is `Object.is`-identical
- * to the last delivered one (identity-stable projections stay quiet).
+ * to the unit's previous projection (identity-stable projections stay quiet;
+ * the baseline advances with every change, heard or not).
* Cells build lazily — a unit registered after events flowed, or a session
* older than the registry, folds `init` over the in-memory log on first
* touch (event or read). Registration is an effect (disposer rides the
@@ -639,13 +641,17 @@ export class SessionProjectionRegistry extends Service {
const changed = !Object.is(next, cell.state)
cell.state = next
cell.observedSeq = event.seq
- if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
+ if (changed && registration.def.wire !== undefined) {
// Identity gate on the raw view: a changed state whose projection is
- // reference-identical to the last delivered one stays quiet, so a
- // unit can buffer working fields without spamming the feed.
+ // reference-identical to the previous one stays quiet, so a unit can
+ // buffer working fields without spamming the feed. The baseline is
+ // stamped on every change — listeners or none — so a later listener
+ // generation cannot be silenced by a value the unobserved state
+ // passed through and returned to.
const raw = registration.def.wire.view(cell.state)
- if (cell.lastView !== undefined && Object.is(cell.lastView.raw, raw)) continue
+ const identical = cell.lastView !== undefined && Object.is(cell.lastView.raw, raw)
cell.lastView = { raw }
+ if (identical || this.listeners.size === 0) continue
const value = registration.def.wire.viewSchema.parse(raw)
for (const listener of this.listeners) {
listener(session, registration.def.key as Extract, value, event.seq)
diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts
index 7a421a2f9f..63d28e68fe 100644
--- a/packages/session/session-projection/tests/registry.spec.ts
+++ b/packages/session/session-projection/tests/registry.spec.ts
@@ -20,11 +20,13 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
'test/marks': MarksState
'test/count': number
'test/buffered': { marks: string[]; draft: string }
+ 'test/label': string
}
interface SessionProjectionMap {
'test/marks': { marks: string[] }
'test/buffered': string[]
+ 'test/label': string
}
}
@@ -149,6 +151,38 @@ describe('SessionProjectionRegistry drive', () => {
expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b'])
})
+ it('advances the dedup baseline while unobserved, so a later listener hears a return to an old value', async () => {
+ const { ctx, session } = await harness()
+ // A primitive-valued view compares by value under Object.is (the title
+ // unit's shape), which is exactly where a stale baseline could silence a
+ // real transition.
+ ctx.sessionProjections.register({
+ key: 'test/label',
+ stateSchema: z.string(),
+ init: () => '',
+ apply: (state, event) => (event.type === 'test/mark' ? event.data.marks[0] ?? '' : state),
+ wire: { viewSchema: z.string(), view: state => state },
+ stateVersion: 1,
+ })
+ const first: string[] = []
+ const stop = ctx.sessionProjections.onChanged((_session, key, value) => {
+ if (key === 'test/label') first.push(value as string)
+ })
+ mark(session, ['A'])
+ stop()
+ // Unobserved transition away from 'A'…
+ mark(session, ['B'])
+ // …then a new listener generation subscribes and the value returns: with
+ // a baseline frozen at 'A' this delivery would be silenced.
+ const second: string[] = []
+ ctx.sessionProjections.onChanged((_session, key, value) => {
+ if (key === 'test/label') second.push(value as string)
+ })
+ mark(session, ['A'])
+ expect(first).toEqual(['A'])
+ expect(second).toEqual(['A'])
+ })
+
it('drives independently per session (cells are per-session watermarks)', async () => {
const { ctx, session } = await harness()
const other = ctx.sessions.create()
From b7053eba799f374068ab9d36c87aab50d94392d5 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Sun, 30 Aug 2026 22:13:26 +0800
Subject: [PATCH 17/26] refactor(session-turn-outline): bound oversized preview
blocks, degrade malformed previews
preview() now slices a single text block to limit * 2 before joining and
normalizing, so one multi-megabyte block no longer pays a full-string pass;
the host projection and the client turn-navigation helper stay mirrored.
outlineEntry keeps dropping entries with damaged turn/seq (marks cannot
exist or jump without them) but degrades malformed prompt/response
previews to empty strings so the turn stays navigable.
---
.../ui-chat/src/client/chat/turn-rail-items.ts | 11 ++++++++---
.../client/conversation-nodes/turn-navigation.ts | 11 ++++++++++-
.../ui-chat/tests/turn-rail-items.client.spec.ts | 7 ++++---
.../session-turn-outline/src/projection.ts | 11 ++++++++++-
.../tests/projection.spec.ts | 16 ++++++++++++++++
5 files changed, 48 insertions(+), 8 deletions(-)
diff --git a/packages/client/ui-chat/src/client/chat/turn-rail-items.ts b/packages/client/ui-chat/src/client/chat/turn-rail-items.ts
index c7ce212563..98089e83c3 100644
--- a/packages/client/ui-chat/src/client/chat/turn-rail-items.ts
+++ b/packages/client/ui-chat/src/client/chat/turn-rail-items.ts
@@ -24,17 +24,22 @@ export interface TurnRailItem {
const EMPTY_ITEMS: readonly TurnRailItem[] = []
-/** Structurally narrow one wire outline entry (projection values cross the wire). */
+/**
+ * Structurally narrow one wire outline entry (projection values cross the
+ * wire). `turn` and `seq` are the load-bearing fields — a mark cannot exist
+ * or jump without them — so their damage drops the entry; the previews are
+ * decorative, so a malformed one degrades to `''` and the turn stays
+ * navigable by number.
+ */
function outlineEntry(value: unknown): { turn: number; seq: number; prompt: string; response: string } | undefined {
if (typeof value !== 'object' || value === null) return undefined
const entry = value as { turn?: unknown; seq?: unknown; prompt?: unknown; response?: unknown }
if (typeof entry.turn !== 'number' || !Number.isSafeInteger(entry.turn) || entry.turn < 0) return undefined
if (typeof entry.seq !== 'number' || !Number.isSafeInteger(entry.seq) || entry.seq < 0) return undefined
- if (typeof entry.prompt !== 'string') return undefined
return {
turn: entry.turn,
seq: entry.seq,
- prompt: entry.prompt,
+ prompt: typeof entry.prompt === 'string' ? entry.prompt : '',
response: typeof entry.response === 'string' ? entry.response : '',
}
}
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts
index ac6b51e323..8687c7afa4 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts
@@ -20,7 +20,16 @@ function preview(parts: Iterable, limit: number): string {
unread = true
break
}
- text += text === '' ? part : ` ${part}`
+ // Per-part bound: this runs on every structural rail update, so one huge
+ // text block must not be concatenated (and regex-normalized) whole for a
+ // preview this short.
+ const clipped = part.length > limit * 2
+ const chunk = clipped ? part.slice(0, limit * 2) : part
+ text += text === '' ? chunk : ` ${chunk}`
+ if (clipped) {
+ unread = true
+ break
+ }
}
const normalized = text.replace(/\s+/g, ' ').trim()
if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…`
diff --git a/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts b/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts
index 598bc198b2..8f9dee2b68 100644
--- a/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts
+++ b/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts
@@ -55,19 +55,20 @@ describe('mergeTurnRailItems', () => {
])
})
- it('drops malformed wire entries and shapes without folding the rail', () => {
+ it('drops entries with damaged navigation fields but degrades malformed previews to empty', () => {
expect(mergeTurnRailItems([loadedItem(1)], 'not an outline')).toEqual([
{ turn: 1, prompt: 'p1', response: 'r1', anchor: { kind: 'loaded', key: 'anchor-1' } },
])
const items = mergeTurnRailItems([], [
{ turn: -1, seq: 0, prompt: 'negative turn', response: '' },
{ turn: 2, seq: 0.5, prompt: 'fractional seq', response: '' },
- { turn: 3, seq: 4, prompt: 5, response: '' },
+ { turn: 3, seq: 4, prompt: 5, response: 6 },
{ turn: 6, seq: 7, prompt: 'kept', response: 8 },
null,
])
- // A non-string response degrades to '' while the entry itself survives.
+ // turn/seq are load-bearing (drop); previews are decorative (degrade).
expect(items).toEqual([
+ { turn: 3, prompt: '', response: '', anchor: { kind: 'unloaded', seq: 4 } },
{ turn: 6, prompt: 'kept', response: '', anchor: { kind: 'unloaded', seq: 7 } },
])
})
diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts
index 7184bf5d17..68ae2bbf0c 100644
--- a/packages/session/session-turn-outline/src/projection.ts
+++ b/packages/session/session-turn-outline/src/projection.ts
@@ -42,7 +42,16 @@ function preview(content: MessageContent, limit: number): string {
unread = true
break
}
- text += text === '' ? block.text : ` ${block.text}`
+ // Per-block bound: the fold runs on every message event, so a single
+ // multi-megabyte block must not be concatenated (and regex-normalized)
+ // whole for a preview this short.
+ const clipped = block.text.length > limit * 2
+ const chunk = clipped ? block.text.slice(0, limit * 2) : block.text
+ text += text === '' ? chunk : ` ${chunk}`
+ if (clipped) {
+ unread = true
+ break
+ }
}
const normalized = text.replace(/\s+/g, ' ').trim()
if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…`
diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts
index 3749fe554c..eb22ea571d 100644
--- a/packages/session/session-turn-outline/tests/projection.spec.ts
+++ b/packages/session/session-turn-outline/tests/projection.spec.ts
@@ -90,6 +90,22 @@ describe('turn outline projection unit', () => {
expect(outlineOf(ctx, session)[0]?.response).toBe('streamed but unsettled')
})
+ it('reads a bounded slice of one oversized text block instead of the whole payload', async () => {
+ const { ctx, session } = await harness(true)
+ session.append('turn/start', { turn: 1 })
+ session.append('user/message', createUserMessage({
+ content: [{ type: 'text', text: `giant ${'g'.repeat(500_000)}` }],
+ source: { kind: 'user' },
+ }), { surfaceOp: 'append' })
+ appendAssistant(session, 1, 1, `answer ${'a'.repeat(500_000)}`)
+ endTurn(session, 1)
+ const entry = outlineOf(ctx, session)[0]
+ expect(entry?.prompt).toMatch(/^giant g+…$/)
+ expect(entry?.prompt).toHaveLength(50)
+ expect(entry?.response).toMatch(/^answer a+…$/)
+ expect(entry?.response).toHaveLength(120)
+ })
+
it('collapses whitespace and caps previews at their card budgets with an ellipsis', async () => {
const { ctx, session } = await harness(true)
session.append('turn/start', { turn: 1 })
From e71db15d1afcf694b11abece0cba0f3fd5eebb7e Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 31 Aug 2026 01:07:42 +0800
Subject: [PATCH 18/26] chore(release): align session-turn-outline with root
0.1.2-alpha.2, mark mirrored preview clone
The dsh 0.1.2-alpha.2 release bumped every workspace after this branch
forked, so the new package failed the workspace version constraint in the
merge tree. The bounded preview() also grew identical enough to its
deliberate host/client mirror to trip clone detection; the client copy now
carries a jscpd ignore region naming the wire-boundary rationale.
---
.../ui-chat/src/client/conversation-nodes/turn-navigation.ts | 4 ++++
packages/session/session-turn-outline/package.json | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts
index 8687c7afa4..331840e80c 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts
@@ -12,6 +12,9 @@ const PROMPT_PREVIEW_LIMIT = 50
const RESPONSE_PREVIEW_LIMIT = 120
/** Join rendered text, collapse whitespace, and cap at `limit` with a trailing ellipsis when clipped. */
+// Deliberate mirror of the turnOutline projection's preview(): the wire
+// boundary forbids sharing code with the host package.
+/* jscpd:ignore-start */
function preview(parts: Iterable, limit: number): string {
let text = ''
let unread = false
@@ -35,6 +38,7 @@ function preview(parts: Iterable, limit: number): string {
if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…`
return unread ? `${normalized}…` : normalized
}
+/* jscpd:ignore-end */
function promptText(node: ChatNode): string {
if (node.kind !== 'user') return ''
diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json
index 7c12ba5162..c1be737fc0 100644
--- a/packages/session/session-turn-outline/package.json
+++ b/packages/session/session-turn-outline/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-session-turn-outline",
"description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness",
- "version": "0.1.2-alpha.1",
+ "version": "0.1.2-alpha.2",
"publishConfig": {
"access": "public"
},
From 9069a8b6f8cd46747654e3e7959a3bfaf038179d Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 31 Aug 2026 14:11:37 +0800
Subject: [PATCH 19/26] refactor(session-projection): compare per-step views
instead of storing a dedup baseline
Review suggestion (imccyu): the drive holds both the previous and next
state, so the identity gate can compute view(previous) and view(next) in
the driving step and compare them directly. The stored lastView cell field
and its stamp-on-every-change rule are deleted; with no dedup memory,
nothing can go stale across listener generations by construction, and a
rebuilt cell no longer pushes an unchanged view on its first live event.
Costs one extra pure view() call per changed state.
---
...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +--
.../2026-08-30-web-turn-rail-outline-jump.md | 2 +-
...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +-
docs/subsystems/session-projection.i18n.yaml | 4 +--
docs/subsystems/session-projection.md | 4 +--
docs/subsystems/session-projection.zh.md | 4 +--
.../extensions/tool-cordis/src/api-catalog.ts | 2 +-
.../session-projection/README.i18n.yaml | 4 +--
packages/session/session-projection/README.md | 2 +-
.../session/session-projection/README.zh.md | 2 +-
.../session/session-projection/src/index.ts | 35 +++++++------------
.../session-projection/tests/registry.spec.ts | 12 ++++---
12 files changed, 35 insertions(+), 42 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
index dc48dfa503..1d118bca77 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
-2026-08-30-web-turn-rail-outline-jump.md: b2803aebf54c342c08da92131db7617b28ad1620
-2026-08-30-web-turn-rail-outline-jump.zh.md: 547e708a76e2205f7e2a1c83c9b3330975136393
+2026-08-30-web-turn-rail-outline-jump.md: ca59263d10dfd84984cae454a2e45027ff1d018e
+2026-08-30-web-turn-rail-outline-jump.zh.md: ab5df81b8521332ef9b3e7f2fac9e3f2fe795d9d
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
index b2803aebf5..ca59263d10 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
@@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone.
**Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn.
-**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output against the unit's previous projection and stays quiet when `Object.is`-identical; the baseline advances on every change, heard or not, so a later listener generation still sees every value transition. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
+**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; both views are computed in the driving step (review moved this off a stored last-delivered value), so no dedup memory exists to go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
**Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
index 547e708a76..ab5df81b85 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
@@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口
**数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。
-**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把原始 `view` 输出与该单元上一次投影值比较,`Object.is` 相同即保持安静;基线随每次变更前进、有无监听者都一样,后来的监听器换代仍能看到每次值转变。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
+**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;两侧视图都在驱动当步现算(评审后从「存上一次交付值」改为现算),不存去重记忆,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
**分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml
index 63a040c196..1a91aaf7c3 100644
--- a/docs/subsystems/session-projection.i18n.yaml
+++ b/docs/subsystems/session-projection.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: b7709b8d6254166afff08688c463161998731864
-session-projection.zh.md: 71da679e0cf50e6527889fd209a43291371ce313
+session-projection.md: 52a745b31c5c6a21d58e0f27f93df812e49e0e98
+session-projection.zh.md: 044351647a9e16df921d8a41478f58ac4f9964d0
diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md
index b7709b8d62..52a745b31c 100644
--- a/docs/subsystems/session-projection.md
+++ b/docs/subsystems/session-projection.md
@@ -98,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the unit's previous projection (the baseline advances with every change, heard or not), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
+`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (both views are computed in the driving step; no stored comparison value exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
## The registry: `ctx.sessionProjections`
@@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; the baseline advances with every change, heard or not). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md
index 71da679e0c..044351647a 100644
--- a/docs/subsystems/session-projection.zh.md
+++ b/docs/subsystems/session-projection.zh.md
@@ -98,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与该单元上一次投影值 `Object.is` 相同(基线随每次变更前进,无论有无监听者),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
+`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(两侧视图都在驱动当步现算,不存比较值故无从过期),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
## 注册表:`ctx.sessionProjections`
@@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; the baseline advances with every change, heard or not). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index a55f3bf514..20926bcafe 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'sessionProjections',
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
- description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; the baseline advances with every change, heard or not). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
+ description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
methods: [
{
signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void',
diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml
index 7a6652ec0e..3fe3d649a0 100644
--- a/packages/session/session-projection/README.i18n.yaml
+++ b/packages/session/session-projection/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
-README.md: c498d6f163d704467f035f07e3da1ce1d484a163
-README.zh.md: eff47a64f2547e56642527cffb05e219d18397bf
+README.md: 753b83c2f8e7fa7ec9bf40b6107f9067c27f8362
+README.zh.md: dd036b4553f406e1cbe1b9f9280c8c682da8d1be
diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md
index c498d6f163..753b83c2f8 100644
--- a/packages/session/session-projection/README.md
+++ b/packages/session/session-projection/README.md
@@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable
### Design concept
-The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the unit's previous projection stays quiet, with the baseline advancing on every change, heard or not (so a unit can buffer working fields behind an identity-stable projection without silencing later listener generations). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
+The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
### Source map
diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md
index eff47a64f2..dd036b4553 100644
--- a/packages/session/session-projection/README.zh.md
+++ b/packages/session/session-projection/README.zh.md
@@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
### 设计理念
-本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与该单元上一次投影值相同的同样保持安静,且基线随每次变更前进、有无监听者都一样(单元因此可以把工作字段缓冲在身份稳定的投影之后,而不会静默掉后来监听者的值变化)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
+本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——两侧视图都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
### 源码地图
diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts
index 3e3879df23..0af012beea 100644
--- a/packages/session/session-projection/src/index.ts
+++ b/packages/session/session-projection/src/index.ts
@@ -144,14 +144,6 @@ interface UnitCell {
state: unknown
/** Seq of the last event passed through `apply` (regardless of change). */
observedSeq: number
- /**
- * Raw (pre-validation) `view` output of the last changed state, stamped
- * whether or not a listener heard it. A changed state whose raw view is
- * `Object.is` to this stays quiet, so a unit can buffer working fields in
- * state by keeping its wire projection identity-stable — and a listener
- * subscribing later still sees every value transition since this baseline.
- */
- lastView?: { raw: unknown }
}
/**
@@ -179,7 +171,8 @@ interface Registration {
* reference in a client-visible unit notifies the change feed with the
* schema-validated view — unless the raw view output is `Object.is`-identical
* to the unit's previous projection (identity-stable projections stay quiet;
- * the baseline advances with every change, heard or not).
+ * both views are computed from the states in hand each step, so no stored
+ * comparison value exists to go stale across listener generations).
* Cells build lazily — a unit registered after events flowed, or a session
* older than the registry, folds `init` over the in-memory log on first
* touch (event or read). Registration is an effect (disposer rides the
@@ -637,21 +630,19 @@ export class SessionProjectionRegistry extends Service {
} else {
this.advanceCell(registration.def, cell, session.events, event.seq - 1)
}
- const next = registration.def.apply(cell.state, event)
- const changed = !Object.is(next, cell.state)
+ const previous = cell.state
+ const next = registration.def.apply(previous, event)
+ const changed = !Object.is(next, previous)
cell.state = next
cell.observedSeq = event.seq
- if (changed && registration.def.wire !== undefined) {
- // Identity gate on the raw view: a changed state whose projection is
- // reference-identical to the previous one stays quiet, so a unit can
- // buffer working fields without spamming the feed. The baseline is
- // stamped on every change — listeners or none — so a later listener
- // generation cannot be silenced by a value the unobserved state
- // passed through and returned to.
- const raw = registration.def.wire.view(cell.state)
- const identical = cell.lastView !== undefined && Object.is(cell.lastView.raw, raw)
- cell.lastView = { raw }
- if (identical || this.listeners.size === 0) continue
+ if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
+ // Identity gate on the raw view, computed from the two states in
+ // hand: a changed state whose projection is reference-identical to
+ // the previous state's stays quiet, so a unit can buffer working
+ // fields without spamming the feed. Nothing is stored, so no
+ // comparison value exists to go stale across listener generations.
+ const raw = registration.def.wire.view(next)
+ if (Object.is(registration.def.wire.view(previous), raw)) continue
const value = registration.def.wire.viewSchema.parse(raw)
for (const listener of this.listeners) {
listener(session, registration.def.key as Extract, value, event.seq)
diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts
index 63d28e68fe..5ab26d4458 100644
--- a/packages/session/session-projection/tests/registry.spec.ts
+++ b/packages/session/session-projection/tests/registry.spec.ts
@@ -151,11 +151,12 @@ describe('SessionProjectionRegistry drive', () => {
expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b'])
})
- it('advances the dedup baseline while unobserved, so a later listener hears a return to an old value', async () => {
+ it('keeps dedup honest across listener generations: a return to an old value after an unobserved change still fires', async () => {
const { ctx, session } = await harness()
// A primitive-valued view compares by value under Object.is (the title
- // unit's shape), which is exactly where a stale baseline could silence a
- // real transition.
+ // unit's shape), which is exactly where remembering a delivered value —
+ // instead of comparing the two states in hand — would silence a real
+ // transition.
ctx.sessionProjections.register({
key: 'test/label',
stateSchema: z.string(),
@@ -172,8 +173,9 @@ describe('SessionProjectionRegistry drive', () => {
stop()
// Unobserved transition away from 'A'…
mark(session, ['B'])
- // …then a new listener generation subscribes and the value returns: with
- // a baseline frozen at 'A' this delivery would be silenced.
+ // …then a new listener generation subscribes and the value returns:
+ // dedup memory frozen at the delivered 'A' would silence this delivery;
+ // the per-step previous-state comparison sees 'B' → 'A' and fires.
const second: string[] = []
ctx.sessionProjections.onChanged((_session, key, value) => {
if (key === 'test/label') second.push(value as string)
From acc23f6d9c34eedc24bad3c14c70fefd04b8bf57 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 31 Aug 2026 14:43:03 +0800
Subject: [PATCH 20/26] feat(session-projection): unit-declared viewKey change
token
Review follow-up (imccyu): comparing view(previous) recomputes the view on
every quiet change. The wire block now takes an optional viewKey(state)
declaring the cheap comparison token; the drive compares tokens across the
previous and next states and calls view only for an actual push. Default
stays the raw view output. turnOutline declares viewKey: state => state.turns,
making the identity-stable-turns convention an explicit contract; the
registration erasure forwards viewKey (dropping it silently reverted the
gate to the fallback, caught by the new view-call-counting test).
---
...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +-
.../2026-08-30-web-turn-rail-outline-jump.md | 2 +-
...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +-
docs/subsystems/session-projection.i18n.yaml | 4 +-
docs/subsystems/session-projection.md | 4 +-
docs/subsystems/session-projection.zh.md | 4 +-
.../extensions/tool-cordis/src/api-catalog.ts | 4 +-
.../session-projection/README.i18n.yaml | 4 +-
packages/session/session-projection/README.md | 2 +-
.../session/session-projection/README.zh.md | 2 +-
.../session/session-projection/src/index.ts | 52 ++++++++++++++-----
.../session-projection/tests/registry.spec.ts | 43 +++++++++++++++
.../session-turn-outline/src/projection.ts | 3 ++
.../tests/projection.spec.ts | 5 ++
14 files changed, 105 insertions(+), 30 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
index 1d118bca77..502a325a8f 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
-2026-08-30-web-turn-rail-outline-jump.md: ca59263d10dfd84984cae454a2e45027ff1d018e
-2026-08-30-web-turn-rail-outline-jump.zh.md: ab5df81b8521332ef9b3e7f2fac9e3f2fe795d9d
+2026-08-30-web-turn-rail-outline-jump.md: 0c14be30a61f277b05ad3da2169cae5924f89f0b
+2026-08-30-web-turn-rail-outline-jump.zh.md: 707d7e5e8c76bf1fb891628fc0730c72f5ce7612
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
index ca59263d10..0c14be30a6 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
@@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone.
**Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn.
-**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; both views are computed in the driving step (review moved this off a stored last-delivered value), so no dedup memory exists to go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
+**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now compares the unit's `viewKey` token (default: the raw `view` output) across the previous and next states and stays quiet when `Object.is`-identical; both tokens are computed in the driving step (review moved this off a stored last-delivered value, then onto a declared cheap token), so no dedup memory exists to go stale and `view` runs only for an actual push. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
**Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
index ab5df81b85..707d7e5e8c 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
@@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口
**数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。
-**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;两侧视图都在驱动当步现算(评审后从「存上一次交付值」改为现算),不存去重记忆,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
+**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在会把单元声明的 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上相互比较,`Object.is` 相同即保持安静;两侧令牌都在驱动当步现算(评审先把「存上一次交付值」改为现算、再落到声明式廉价令牌),不存去重记忆,`view` 只在真推送时运行。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
**分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml
index 1a91aaf7c3..714becf73d 100644
--- a/docs/subsystems/session-projection.i18n.yaml
+++ b/docs/subsystems/session-projection.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: 52a745b31c5c6a21d58e0f27f93df812e49e0e98
-session-projection.zh.md: 044351647a9e16df921d8a41478f58ac4f9964d0
+session-projection.md: b241c7dbceab23850e9a3755076ee23f83d1ecda
+session-projection.zh.md: 53cb8c251f54ee0343eccab57533d9a0d91f993a
diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md
index 52a745b31c..b241c7dbce 100644
--- a/docs/subsystems/session-projection.md
+++ b/docs/subsystems/session-projection.md
@@ -98,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (both views are computed in the driving step; no stored comparison value exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
+`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the unit's `viewKey` token (default: the raw `view` output) is `Object.is`-identical across the previous and next states (both tokens are computed in the driving step — no stored comparison value exists to go stale, and a declared `viewKey` keeps `view` off the quiet path), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
## The registry: `ctx.sessionProjections`
@@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the unit's `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md
index 044351647a..53cb8c251f 100644
--- a/docs/subsystems/session-projection.zh.md
+++ b/docs/subsystems/session-projection.zh.md
@@ -98,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(两侧视图都在驱动当步现算,不存比较值故无从过期),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
+`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上 `Object.is` 相同(两侧令牌都在驱动当步现算,不存比较值故无从过期;声明了 `viewKey` 时安静路径完全不跑 `view`),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
## 注册表:`ctx.sessionProjections`
@@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the unit's `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 20926bcafe..e0548165b6 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'sessionProjections',
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
- description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
+ description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the unit\'s `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
methods: [
{
signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void',
@@ -4593,7 +4593,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ProjectionDefinition',
- declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}',
+ declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n viewKey?(state: NoInfer): unknown;\n } : never;\n stateVersion: number;\n}',
},
{
name: 'ProjectionSnapshot',
diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml
index 3fe3d649a0..a6dbc00807 100644
--- a/packages/session/session-projection/README.i18n.yaml
+++ b/packages/session/session-projection/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
-README.md: 753b83c2f8e7fa7ec9bf40b6107f9067c27f8362
-README.zh.md: dd036b4553f406e1cbe1b9f9280c8c682da8d1be
+README.md: 2e4a5f7897c62e18899f92e3aa04d9ccff51b161
+README.zh.md: 7b02d534cb8dc7c6150084b7aa140200210d7b71
diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md
index 753b83c2f8..2e4a5f7897 100644
--- a/packages/session/session-projection/README.md
+++ b/packages/session/session-projection/README.md
@@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable
### Design concept
-The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
+The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose `viewKey` token (default: the raw `view` output) is identical across the previous and next states stays quiet — both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and a declared `viewKey` keeps `view` itself off the quiet path (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
### Source map
diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md
index dd036b4553..7b02d534cb 100644
--- a/packages/session/session-projection/README.zh.md
+++ b/packages/session/session-projection/README.zh.md
@@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
### 设计理念
-本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——两侧视图都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
+本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上相同的同样保持安静——两侧令牌都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期;声明了 `viewKey` 的单元在安静路径上完全不跑 `view`(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
### 源码地图
diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts
index 0af012beea..7053fbaf13 100644
--- a/packages/session/session-projection/src/index.ts
+++ b/packages/session/session-projection/src/index.ts
@@ -72,6 +72,18 @@ export interface ProjectionDefinition<
* @returns the whole current value for this unit's key.
*/
view(state: NoInfer): SessionProjectionMap[K]
+ /**
+ * Change-detection token for the served view: after a changed `apply`,
+ * the feed compares this token across the previous and next states with
+ * `Object.is` and stays quiet when identical, so `view` runs only for an
+ * actual push. Must be a cheap pure read (a state field, not a
+ * computation). Omitted, the raw `view` output itself is the token —
+ * correct for identity-stable views, but then `view` runs per changed
+ * state, and views building fresh objects per call push on every change.
+ * @param state - a state on either side of the comparison.
+ * @returns the token deciding whether the served view changed.
+ */
+ viewKey?(state: NoInfer): unknown
} : never
/**
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
@@ -135,7 +147,11 @@ interface ErasedDefinition {
stateSchema: { parse(value: unknown): unknown }
init(header: SessionHeader): unknown
apply(state: unknown, event: SessionEvent): unknown
- wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined
+ wire: {
+ viewSchema: { parse(value: unknown): unknown }
+ view(state: unknown): unknown
+ viewKey?(state: unknown): unknown
+ } | undefined
stateVersion: number
}
@@ -169,10 +185,11 @@ interface Registration {
* service subscribes to `session/event` once; every committed event passes
* every registered unit's `apply` (eager drive), and a changed state
* reference in a client-visible unit notifies the change feed with the
- * schema-validated view — unless the raw view output is `Object.is`-identical
- * to the unit's previous projection (identity-stable projections stay quiet;
- * both views are computed from the states in hand each step, so no stored
- * comparison value exists to go stale across listener generations).
+ * schema-validated view — unless the unit's `viewKey` token (default: the raw
+ * view output) is `Object.is`-identical across the previous and next states.
+ * Both tokens are computed from the states in hand each step, so no stored
+ * comparison value exists to go stale across listener generations, and
+ * `view` runs only for an actual push when a `viewKey` is declared.
* Cells build lazily — a unit registered after events flowed, or a session
* older than the registry, folds `init` over the in-memory log on first
* touch (event or read). Registration is an effect (disposer rides the
@@ -244,7 +261,9 @@ export class SessionProjectionRegistry extends Service {
const wire = definition.wire as {
viewSchema: ZodType
view(state: S): unknown
+ viewKey?(state: S): unknown
} | undefined
+ const viewKey = wire?.viewKey
const erased: ErasedDefinition = {
key: definition.key,
stateSchema: definition.stateSchema,
@@ -252,7 +271,11 @@ export class SessionProjectionRegistry extends Service {
apply: (state, event) => definition.apply(state as S, event),
wire: wire === undefined
? undefined
- : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) },
+ : {
+ viewSchema: wire.viewSchema,
+ view: state => wire.view(state as S),
+ ...(viewKey === undefined ? {} : { viewKey: (state: unknown) => viewKey(state as S) }),
+ },
stateVersion: definition.stateVersion,
}
if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) {
@@ -636,14 +659,15 @@ export class SessionProjectionRegistry extends Service {
cell.state = next
cell.observedSeq = event.seq
if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
- // Identity gate on the raw view, computed from the two states in
- // hand: a changed state whose projection is reference-identical to
- // the previous state's stays quiet, so a unit can buffer working
- // fields without spamming the feed. Nothing is stored, so no
- // comparison value exists to go stale across listener generations.
- const raw = registration.def.wire.view(next)
- if (Object.is(registration.def.wire.view(previous), raw)) continue
- const value = registration.def.wire.viewSchema.parse(raw)
+ // Identity gate on the unit's comparison token, computed from the
+ // two states in hand: identical tokens mean the served view did not
+ // change, so a unit can buffer working fields without spamming the
+ // feed and `view` runs only for an actual push. Nothing is stored,
+ // so no comparison value exists to go stale across listener
+ // generations.
+ const keyOf = registration.def.wire.viewKey ?? registration.def.wire.view
+ if (Object.is(keyOf(previous), keyOf(next))) continue
+ const value = registration.def.wire.viewSchema.parse(registration.def.wire.view(next))
for (const listener of this.listeners) {
listener(session, registration.def.key as Extract, value, event.seq)
}
diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts
index 5ab26d4458..585853d2a5 100644
--- a/packages/session/session-projection/tests/registry.spec.ts
+++ b/packages/session/session-projection/tests/registry.spec.ts
@@ -151,6 +151,49 @@ describe('SessionProjectionRegistry drive', () => {
expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b'])
})
+ it('compares by the declared viewKey token and runs view only for a push', async () => {
+ const { ctx, session } = await harness()
+ let viewCalls = 0
+ // A fresh-array view would push on every changed state under the default
+ // raw-view token; the declared token pins dedup to `marks` identity and
+ // keeps `view` off the quiet path entirely.
+ ctx.sessionProjections.register({
+ key: 'test/buffered',
+ stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }),
+ init: () => ({ marks: [], draft: '' }),
+ apply: (state, event) => {
+ if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' }
+ if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` }
+ return state
+ },
+ wire: {
+ viewSchema: z.array(z.string()),
+ view: (state) => {
+ viewCalls += 1
+ return [...state.marks]
+ },
+ viewKey: state => state.marks,
+ },
+ stateVersion: 1,
+ })
+ const seen: { value: unknown; seq: number }[] = []
+ ctx.sessionProjections.onChanged((_session, key, value, seq) => {
+ if (key === 'test/buffered') seen.push({ value, seq })
+ })
+ const first = mark(session, ['a'])
+ expect(viewCalls).toBe(1)
+ // Draft-only apply: state reference moves, the token does not — and the
+ // fresh-object view is never consulted.
+ session.append('turn/start', { turn: 1 })
+ expect(viewCalls).toBe(1)
+ const second = mark(session, ['a', 'b'])
+ expect(seen).toEqual([
+ { value: ['a'], seq: first.seq },
+ { value: ['a', 'b'], seq: second.seq },
+ ])
+ expect(viewCalls).toBe(2)
+ })
+
it('keeps dedup honest across listener generations: a return to an old value after an unobserved change still fires', async () => {
const { ctx, session } = await harness()
// A primitive-valued view compares by value under Object.is (the title
diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts
index 68ae2bbf0c..811b699430 100644
--- a/packages/session/session-turn-outline/src/projection.ts
+++ b/packages/session/session-turn-outline/src/projection.ts
@@ -133,5 +133,8 @@ export const turnOutlineProjectionDefinition = {
wire: {
viewSchema: turnOutlineEntriesSchema,
view: state => state.turns,
+ // Draft-only applies replace the state object but reuse `turns`, so this
+ // token keeps the feed quiet until a turn-level commit.
+ viewKey: state => state.turns,
},
} satisfies ProjectionDefinition<'turnOutline', TurnOutlineState>
diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts
index eb22ea571d..2006290d2b 100644
--- a/packages/session/session-turn-outline/tests/projection.spec.ts
+++ b/packages/session/session-turn-outline/tests/projection.spec.ts
@@ -262,4 +262,9 @@ describe('turn outline projection unit', () => {
},
}, [], 0, session.header)).not.toThrow()
})
+
+ it('keys change detection to the turns array identity, so draft-only applies stay quiet without a view call', () => {
+ const state = { turns: [{ turn: 1, seq: 1, prompt: 'p', response: '' }], draft: 'buffering' }
+ expect(turnOutlineProjectionDefinition.wire.viewKey(state)).toBe(state.turns)
+ })
})
From 9836fdbbd7e5124ea5878c9adcf8110de8f5c9a2 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 31 Aug 2026 14:44:16 +0800
Subject: [PATCH 21/26] docs(session-projection): describe the change feed by
its viewKey token
---
packages/session/session-projection/src/index.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts
index 7053fbaf13..4df77d911a 100644
--- a/packages/session/session-projection/src/index.ts
+++ b/packages/session/session-projection/src/index.ts
@@ -98,9 +98,9 @@ export interface ProjectionDefinition<
* Change-feed listener: one unit's served value changed for one session.
* `value` is the schema-validated `view` output; `seq` is the unit's
* watermark at emission (the seq of the event that caused the change). A
- * changed state whose raw `view` output is `Object.is`-identical to the
- * unit's previous projection does not fire, so a unit can buffer working
- * fields in state behind an identity-stable projection.
+ * changed state whose `viewKey` token (default: the raw `view` output) is
+ * `Object.is`-identical to the previous state's does not fire, so a unit can
+ * buffer working fields in state behind an identity-stable projection.
*/
export type ProjectionChangeListener = (
session: Session,
From f2e4078d8c25c40c1e7dc6190845c887a2e6b6a5 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 31 Aug 2026 14:46:29 +0800
Subject: [PATCH 22/26] docs(session-projection): carry viewKey through the
subsystem type fences
---
docs/subsystems/session-projection.i18n.yaml | 4 ++--
docs/subsystems/session-projection.md | 18 +++++++++++++++---
docs/subsystems/session-projection.zh.md | 18 +++++++++++++++---
3 files changed, 32 insertions(+), 8 deletions(-)
diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml
index 714becf73d..bb2b34d2c8 100644
--- a/docs/subsystems/session-projection.i18n.yaml
+++ b/docs/subsystems/session-projection.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: b241c7dbceab23850e9a3755076ee23f83d1ecda
-session-projection.zh.md: 53cb8c251f54ee0343eccab57533d9a0d91f993a
+session-projection.md: 5943281952def6e86f819e73c7822dc7f6ed4ed6
+session-projection.zh.md: 99009a0472ed40f418527848e91523d0ff0e2303
diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md
index b241c7dbce..5943281952 100644
--- a/docs/subsystems/session-projection.md
+++ b/docs/subsystems/session-projection.md
@@ -52,6 +52,18 @@ interface ProjectionDefinition<
* @returns the whole current value for this unit's key.
*/
view(state: NoInfer): SessionProjectionMap[K]
+ /**
+ * Change-detection token for the served view: after a changed `apply`,
+ * the feed compares this token across the previous and next states with
+ * `Object.is` and stays quiet when identical, so `view` runs only for an
+ * actual push. Must be a cheap pure read (a state field, not a
+ * computation). Omitted, the raw `view` output itself is the token —
+ * correct for identity-stable views, but then `view` runs per changed
+ * state, and views building fresh objects per call push on every change.
+ * @param state - a state on either side of the comparison.
+ * @returns the token deciding whether the served view changed.
+ */
+ viewKey?(state: NoInfer): unknown
} : never
/**
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
@@ -86,9 +98,9 @@ interface ProjectionSnapshot {
* Change-feed listener: one unit's served value changed for one session.
* `value` is the schema-validated `view` output; `seq` is the unit's
* watermark at emission (the seq of the event that caused the change). A
- * changed state whose raw `view` output is `Object.is`-identical to the
- * unit's previous projection does not fire, so a unit can buffer working
- * fields in state behind an identity-stable projection.
+ * changed state whose `viewKey` token (default: the raw `view` output) is
+ * `Object.is`-identical to the previous state's does not fire, so a unit can
+ * buffer working fields in state behind an identity-stable projection.
*/
type ProjectionChangeListener = (
session: Session,
diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md
index 53cb8c251f..99009a0472 100644
--- a/docs/subsystems/session-projection.zh.md
+++ b/docs/subsystems/session-projection.zh.md
@@ -52,6 +52,18 @@ interface ProjectionDefinition<
* @returns the whole current value for this unit's key.
*/
view(state: NoInfer): SessionProjectionMap[K]
+ /**
+ * Change-detection token for the served view: after a changed `apply`,
+ * the feed compares this token across the previous and next states with
+ * `Object.is` and stays quiet when identical, so `view` runs only for an
+ * actual push. Must be a cheap pure read (a state field, not a
+ * computation). Omitted, the raw `view` output itself is the token —
+ * correct for identity-stable views, but then `view` runs per changed
+ * state, and views building fresh objects per call push on every change.
+ * @param state - a state on either side of the comparison.
+ * @returns the token deciding whether the served view changed.
+ */
+ viewKey?(state: NoInfer): unknown
} : never
/**
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
@@ -86,9 +98,9 @@ interface ProjectionSnapshot {
* Change-feed listener: one unit's served value changed for one session.
* `value` is the schema-validated `view` output; `seq` is the unit's
* watermark at emission (the seq of the event that caused the change). A
- * changed state whose raw `view` output is `Object.is`-identical to the
- * unit's previous projection does not fire, so a unit can buffer working
- * fields in state behind an identity-stable projection.
+ * changed state whose `viewKey` token (default: the raw `view` output) is
+ * `Object.is`-identical to the previous state's does not fire, so a unit can
+ * buffer working fields in state behind an identity-stable projection.
*/
type ProjectionChangeListener = (
session: Session,
From a2437db180c9afa15778924f5f024f60aaf158cd Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 31 Aug 2026 15:44:14 +0800
Subject: [PATCH 23/26] revert(session-projection): drop the viewKey token,
keep the per-step raw-view gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review consensus: for the only declaring unit the token function was
literally the view function, so the extra wire member bought nothing —
default raw-view comparison produces the identical Object.is on
state.turns at the identical cost (one property read per side). The
per-step gate stays: it holds the no-stored-baseline property, and its
measured overhead (~ns per changed state) is four orders of magnitude
below the push path it guards.
---
...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +-
.../2026-08-30-web-turn-rail-outline-jump.md | 2 +-
...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +-
docs/subsystems/session-projection.i18n.yaml | 4 +-
docs/subsystems/session-projection.md | 22 ++-----
docs/subsystems/session-projection.zh.md | 22 ++-----
.../extensions/tool-cordis/src/api-catalog.ts | 4 +-
.../session-projection/README.i18n.yaml | 4 +-
packages/session/session-projection/README.md | 2 +-
.../session/session-projection/README.zh.md | 2 +-
.../session/session-projection/src/index.ts | 58 ++++++-------------
.../session-projection/tests/registry.spec.ts | 43 --------------
.../session-turn-outline/src/projection.ts | 3 -
.../tests/projection.spec.ts | 5 --
14 files changed, 39 insertions(+), 138 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
index 502a325a8f..1d118bca77 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
-2026-08-30-web-turn-rail-outline-jump.md: 0c14be30a61f277b05ad3da2169cae5924f89f0b
-2026-08-30-web-turn-rail-outline-jump.zh.md: 707d7e5e8c76bf1fb891628fc0730c72f5ce7612
+2026-08-30-web-turn-rail-outline-jump.md: ca59263d10dfd84984cae454a2e45027ff1d018e
+2026-08-30-web-turn-rail-outline-jump.zh.md: ab5df81b8521332ef9b3e7f2fac9e3f2fe795d9d
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
index 0c14be30a6..ca59263d10 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
@@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone.
**Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn.
-**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now compares the unit's `viewKey` token (default: the raw `view` output) across the previous and next states and stays quiet when `Object.is`-identical; both tokens are computed in the driving step (review moved this off a stored last-delivered value, then onto a declared cheap token), so no dedup memory exists to go stale and `view` runs only for an actual push. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
+**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; both views are computed in the driving step (review moved this off a stored last-delivered value), so no dedup memory exists to go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
**Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
index 707d7e5e8c..ab5df81b85 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
@@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口
**数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。
-**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在会把单元声明的 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上相互比较,`Object.is` 相同即保持安静;两侧令牌都在驱动当步现算(评审先把「存上一次交付值」改为现算、再落到声明式廉价令牌),不存去重记忆,`view` 只在真推送时运行。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
+**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;两侧视图都在驱动当步现算(评审后从「存上一次交付值」改为现算),不存去重记忆,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
**分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml
index bb2b34d2c8..1a91aaf7c3 100644
--- a/docs/subsystems/session-projection.i18n.yaml
+++ b/docs/subsystems/session-projection.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: 5943281952def6e86f819e73c7822dc7f6ed4ed6
-session-projection.zh.md: 99009a0472ed40f418527848e91523d0ff0e2303
+session-projection.md: 52a745b31c5c6a21d58e0f27f93df812e49e0e98
+session-projection.zh.md: 044351647a9e16df921d8a41478f58ac4f9964d0
diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md
index 5943281952..52a745b31c 100644
--- a/docs/subsystems/session-projection.md
+++ b/docs/subsystems/session-projection.md
@@ -52,18 +52,6 @@ interface ProjectionDefinition<
* @returns the whole current value for this unit's key.
*/
view(state: NoInfer): SessionProjectionMap[K]
- /**
- * Change-detection token for the served view: after a changed `apply`,
- * the feed compares this token across the previous and next states with
- * `Object.is` and stays quiet when identical, so `view` runs only for an
- * actual push. Must be a cheap pure read (a state field, not a
- * computation). Omitted, the raw `view` output itself is the token —
- * correct for identity-stable views, but then `view` runs per changed
- * state, and views building fresh objects per call push on every change.
- * @param state - a state on either side of the comparison.
- * @returns the token deciding whether the served view changed.
- */
- viewKey?(state: NoInfer): unknown
} : never
/**
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
@@ -98,9 +86,9 @@ interface ProjectionSnapshot {
* Change-feed listener: one unit's served value changed for one session.
* `value` is the schema-validated `view` output; `seq` is the unit's
* watermark at emission (the seq of the event that caused the change). A
- * changed state whose `viewKey` token (default: the raw `view` output) is
- * `Object.is`-identical to the previous state's does not fire, so a unit can
- * buffer working fields in state behind an identity-stable projection.
+ * changed state whose raw `view` output is `Object.is`-identical to the
+ * unit's previous projection does not fire, so a unit can buffer working
+ * fields in state behind an identity-stable projection.
*/
type ProjectionChangeListener = (
session: Session,
@@ -110,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the unit's `viewKey` token (default: the raw `view` output) is `Object.is`-identical across the previous and next states (both tokens are computed in the driving step — no stored comparison value exists to go stale, and a declared `viewKey` keeps `view` off the quiet path), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
+`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (both views are computed in the driving step; no stored comparison value exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
## The registry: `ctx.sessionProjections`
@@ -192,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the unit's `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md
index 99009a0472..044351647a 100644
--- a/docs/subsystems/session-projection.zh.md
+++ b/docs/subsystems/session-projection.zh.md
@@ -52,18 +52,6 @@ interface ProjectionDefinition<
* @returns the whole current value for this unit's key.
*/
view(state: NoInfer): SessionProjectionMap[K]
- /**
- * Change-detection token for the served view: after a changed `apply`,
- * the feed compares this token across the previous and next states with
- * `Object.is` and stays quiet when identical, so `view` runs only for an
- * actual push. Must be a cheap pure read (a state field, not a
- * computation). Omitted, the raw `view` output itself is the token —
- * correct for identity-stable views, but then `view` runs per changed
- * state, and views building fresh objects per call push on every change.
- * @param state - a state on either side of the comparison.
- * @returns the token deciding whether the served view changed.
- */
- viewKey?(state: NoInfer): unknown
} : never
/**
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
@@ -98,9 +86,9 @@ interface ProjectionSnapshot {
* Change-feed listener: one unit's served value changed for one session.
* `value` is the schema-validated `view` output; `seq` is the unit's
* watermark at emission (the seq of the event that caused the change). A
- * changed state whose `viewKey` token (default: the raw `view` output) is
- * `Object.is`-identical to the previous state's does not fire, so a unit can
- * buffer working fields in state behind an identity-stable projection.
+ * changed state whose raw `view` output is `Object.is`-identical to the
+ * unit's previous projection does not fire, so a unit can buffer working
+ * fields in state behind an identity-stable projection.
*/
type ProjectionChangeListener = (
session: Session,
@@ -110,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上 `Object.is` 相同(两侧令牌都在驱动当步现算,不存比较值故无从过期;声明了 `viewKey` 时安静路径完全不跑 `view`),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
+`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(两侧视图都在驱动当步现算,不存比较值故无从过期),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
## 注册表:`ctx.sessionProjections`
@@ -192,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the unit's `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index e0548165b6..20926bcafe 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'sessionProjections',
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
- description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the unit\'s `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
+ description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
methods: [
{
signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void',
@@ -4593,7 +4593,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ProjectionDefinition',
- declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n viewKey?(state: NoInfer): unknown;\n } : never;\n stateVersion: number;\n}',
+ declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}',
},
{
name: 'ProjectionSnapshot',
diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml
index a6dbc00807..3fe3d649a0 100644
--- a/packages/session/session-projection/README.i18n.yaml
+++ b/packages/session/session-projection/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
-README.md: 2e4a5f7897c62e18899f92e3aa04d9ccff51b161
-README.zh.md: 7b02d534cb8dc7c6150084b7aa140200210d7b71
+README.md: 753b83c2f8e7fa7ec9bf40b6107f9067c27f8362
+README.zh.md: dd036b4553f406e1cbe1b9f9280c8c682da8d1be
diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md
index 2e4a5f7897..753b83c2f8 100644
--- a/packages/session/session-projection/README.md
+++ b/packages/session/session-projection/README.md
@@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable
### Design concept
-The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose `viewKey` token (default: the raw `view` output) is identical across the previous and next states stays quiet — both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and a declared `viewKey` keeps `view` itself off the quiet path (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
+The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
### Source map
diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md
index 7b02d534cb..dd036b4553 100644
--- a/packages/session/session-projection/README.zh.md
+++ b/packages/session/session-projection/README.zh.md
@@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
### 设计理念
-本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上相同的同样保持安静——两侧令牌都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期;声明了 `viewKey` 的单元在安静路径上完全不跑 `view`(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
+本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——两侧视图都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
### 源码地图
diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts
index 4df77d911a..0af012beea 100644
--- a/packages/session/session-projection/src/index.ts
+++ b/packages/session/session-projection/src/index.ts
@@ -72,18 +72,6 @@ export interface ProjectionDefinition<
* @returns the whole current value for this unit's key.
*/
view(state: NoInfer): SessionProjectionMap[K]
- /**
- * Change-detection token for the served view: after a changed `apply`,
- * the feed compares this token across the previous and next states with
- * `Object.is` and stays quiet when identical, so `view` runs only for an
- * actual push. Must be a cheap pure read (a state field, not a
- * computation). Omitted, the raw `view` output itself is the token —
- * correct for identity-stable views, but then `view` runs per changed
- * state, and views building fresh objects per call push on every change.
- * @param state - a state on either side of the comparison.
- * @returns the token deciding whether the served view changed.
- */
- viewKey?(state: NoInfer): unknown
} : never
/**
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
@@ -98,9 +86,9 @@ export interface ProjectionDefinition<
* Change-feed listener: one unit's served value changed for one session.
* `value` is the schema-validated `view` output; `seq` is the unit's
* watermark at emission (the seq of the event that caused the change). A
- * changed state whose `viewKey` token (default: the raw `view` output) is
- * `Object.is`-identical to the previous state's does not fire, so a unit can
- * buffer working fields in state behind an identity-stable projection.
+ * changed state whose raw `view` output is `Object.is`-identical to the
+ * unit's previous projection does not fire, so a unit can buffer working
+ * fields in state behind an identity-stable projection.
*/
export type ProjectionChangeListener = (
session: Session,
@@ -147,11 +135,7 @@ interface ErasedDefinition {
stateSchema: { parse(value: unknown): unknown }
init(header: SessionHeader): unknown
apply(state: unknown, event: SessionEvent): unknown
- wire: {
- viewSchema: { parse(value: unknown): unknown }
- view(state: unknown): unknown
- viewKey?(state: unknown): unknown
- } | undefined
+ wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined
stateVersion: number
}
@@ -185,11 +169,10 @@ interface Registration {
* service subscribes to `session/event` once; every committed event passes
* every registered unit's `apply` (eager drive), and a changed state
* reference in a client-visible unit notifies the change feed with the
- * schema-validated view — unless the unit's `viewKey` token (default: the raw
- * view output) is `Object.is`-identical across the previous and next states.
- * Both tokens are computed from the states in hand each step, so no stored
- * comparison value exists to go stale across listener generations, and
- * `view` runs only for an actual push when a `viewKey` is declared.
+ * schema-validated view — unless the raw view output is `Object.is`-identical
+ * to the unit's previous projection (identity-stable projections stay quiet;
+ * both views are computed from the states in hand each step, so no stored
+ * comparison value exists to go stale across listener generations).
* Cells build lazily — a unit registered after events flowed, or a session
* older than the registry, folds `init` over the in-memory log on first
* touch (event or read). Registration is an effect (disposer rides the
@@ -261,9 +244,7 @@ export class SessionProjectionRegistry extends Service {
const wire = definition.wire as {
viewSchema: ZodType
view(state: S): unknown
- viewKey?(state: S): unknown
} | undefined
- const viewKey = wire?.viewKey
const erased: ErasedDefinition = {
key: definition.key,
stateSchema: definition.stateSchema,
@@ -271,11 +252,7 @@ export class SessionProjectionRegistry extends Service {
apply: (state, event) => definition.apply(state as S, event),
wire: wire === undefined
? undefined
- : {
- viewSchema: wire.viewSchema,
- view: state => wire.view(state as S),
- ...(viewKey === undefined ? {} : { viewKey: (state: unknown) => viewKey(state as S) }),
- },
+ : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) },
stateVersion: definition.stateVersion,
}
if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) {
@@ -659,15 +636,14 @@ export class SessionProjectionRegistry extends Service {
cell.state = next
cell.observedSeq = event.seq
if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
- // Identity gate on the unit's comparison token, computed from the
- // two states in hand: identical tokens mean the served view did not
- // change, so a unit can buffer working fields without spamming the
- // feed and `view` runs only for an actual push. Nothing is stored,
- // so no comparison value exists to go stale across listener
- // generations.
- const keyOf = registration.def.wire.viewKey ?? registration.def.wire.view
- if (Object.is(keyOf(previous), keyOf(next))) continue
- const value = registration.def.wire.viewSchema.parse(registration.def.wire.view(next))
+ // Identity gate on the raw view, computed from the two states in
+ // hand: a changed state whose projection is reference-identical to
+ // the previous state's stays quiet, so a unit can buffer working
+ // fields without spamming the feed. Nothing is stored, so no
+ // comparison value exists to go stale across listener generations.
+ const raw = registration.def.wire.view(next)
+ if (Object.is(registration.def.wire.view(previous), raw)) continue
+ const value = registration.def.wire.viewSchema.parse(raw)
for (const listener of this.listeners) {
listener(session, registration.def.key as Extract, value, event.seq)
}
diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts
index 585853d2a5..5ab26d4458 100644
--- a/packages/session/session-projection/tests/registry.spec.ts
+++ b/packages/session/session-projection/tests/registry.spec.ts
@@ -151,49 +151,6 @@ describe('SessionProjectionRegistry drive', () => {
expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b'])
})
- it('compares by the declared viewKey token and runs view only for a push', async () => {
- const { ctx, session } = await harness()
- let viewCalls = 0
- // A fresh-array view would push on every changed state under the default
- // raw-view token; the declared token pins dedup to `marks` identity and
- // keeps `view` off the quiet path entirely.
- ctx.sessionProjections.register({
- key: 'test/buffered',
- stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }),
- init: () => ({ marks: [], draft: '' }),
- apply: (state, event) => {
- if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' }
- if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` }
- return state
- },
- wire: {
- viewSchema: z.array(z.string()),
- view: (state) => {
- viewCalls += 1
- return [...state.marks]
- },
- viewKey: state => state.marks,
- },
- stateVersion: 1,
- })
- const seen: { value: unknown; seq: number }[] = []
- ctx.sessionProjections.onChanged((_session, key, value, seq) => {
- if (key === 'test/buffered') seen.push({ value, seq })
- })
- const first = mark(session, ['a'])
- expect(viewCalls).toBe(1)
- // Draft-only apply: state reference moves, the token does not — and the
- // fresh-object view is never consulted.
- session.append('turn/start', { turn: 1 })
- expect(viewCalls).toBe(1)
- const second = mark(session, ['a', 'b'])
- expect(seen).toEqual([
- { value: ['a'], seq: first.seq },
- { value: ['a', 'b'], seq: second.seq },
- ])
- expect(viewCalls).toBe(2)
- })
-
it('keeps dedup honest across listener generations: a return to an old value after an unobserved change still fires', async () => {
const { ctx, session } = await harness()
// A primitive-valued view compares by value under Object.is (the title
diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts
index 811b699430..68ae2bbf0c 100644
--- a/packages/session/session-turn-outline/src/projection.ts
+++ b/packages/session/session-turn-outline/src/projection.ts
@@ -133,8 +133,5 @@ export const turnOutlineProjectionDefinition = {
wire: {
viewSchema: turnOutlineEntriesSchema,
view: state => state.turns,
- // Draft-only applies replace the state object but reuse `turns`, so this
- // token keeps the feed quiet until a turn-level commit.
- viewKey: state => state.turns,
},
} satisfies ProjectionDefinition<'turnOutline', TurnOutlineState>
diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts
index 2006290d2b..eb22ea571d 100644
--- a/packages/session/session-turn-outline/tests/projection.spec.ts
+++ b/packages/session/session-turn-outline/tests/projection.spec.ts
@@ -262,9 +262,4 @@ describe('turn outline projection unit', () => {
},
}, [], 0, session.header)).not.toThrow()
})
-
- it('keys change detection to the turns array identity, so draft-only applies stay quiet without a view call', () => {
- const state = { turns: [{ turn: 1, seq: 1, prompt: 'p', response: '' }], draft: 'buffering' }
- expect(turnOutlineProjectionDefinition.wire.viewKey(state)).toBe(state.turns)
- })
})
From 12bef3b577c6ce2b44651bf0fc28db282fc12fb9 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 31 Aug 2026 16:05:56 +0800
Subject: [PATCH 24/26] perf(session-projection): memoize raw views by state
identity
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review follow-up: the per-step gate recomputed view(previous) on every
changed apply — a property read for identity-stable views, but a fresh
throwaway object per change for computing views. The registry now keeps a
WeakMap from state object to raw view: the previous state's view was
cached when that state was current, so each distinct state's view computes
exactly once (gate and snapshot share the memo) and the quiet path
allocates nothing. Unlike the earlier lastView record, an entry is keyed
by the state itself — the view of that exact state by the pure-view
contract — so no stamping discipline exists to get wrong. Primitive
states bypass the WeakMap and compute directly.
---
...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +-
.../2026-08-30-web-turn-rail-outline-jump.md | 2 +-
...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +-
docs/subsystems/session-projection.i18n.yaml | 4 +-
docs/subsystems/session-projection.md | 4 +-
docs/subsystems/session-projection.zh.md | 4 +-
.../extensions/tool-cordis/src/api-catalog.ts | 2 +-
.../session-projection/README.i18n.yaml | 4 +-
packages/session/session-projection/README.md | 2 +-
.../session/session-projection/README.zh.md | 2 +-
.../session/session-projection/src/index.ts | 47 ++++++++++++++-----
.../session-projection/tests/registry.spec.ts | 39 +++++++++++++++
12 files changed, 89 insertions(+), 27 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
index 1d118bca77..36a5d28b87 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
-2026-08-30-web-turn-rail-outline-jump.md: ca59263d10dfd84984cae454a2e45027ff1d018e
-2026-08-30-web-turn-rail-outline-jump.zh.md: ab5df81b8521332ef9b3e7f2fac9e3f2fe795d9d
+2026-08-30-web-turn-rail-outline-jump.md: c1f190832ebfb83200d8f7cb5c01b9a45167ccab
+2026-08-30-web-turn-rail-outline-jump.zh.md: 66196430ce095e822a5e05863f591ea5cebce570
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
index ca59263d10..c1f190832e 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
@@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone.
**Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn.
-**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; both views are computed in the driving step (review moved this off a stored last-delivered value), so no dedup memory exists to go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
+**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; views are memoized by state object identity (review moved this off a stored last-delivered value, then off per-step recomputation), so each distinct state's view computes once and the memo — keyed by the state itself, not by what the feed delivered — cannot go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
**Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
index ab5df81b85..66196430ce 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
@@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口
**数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。
-**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;两侧视图都在驱动当步现算(评审后从「存上一次交付值」改为现算),不存去重记忆,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
+**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;视图按状态对象身份做备忘(评审先从「存上一次交付值」改为现算、再改为按状态键缓存),每个不同状态只算一次,备忘的键是状态本身而非交付历史,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
**分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml
index 1a91aaf7c3..0f80f31420 100644
--- a/docs/subsystems/session-projection.i18n.yaml
+++ b/docs/subsystems/session-projection.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: 52a745b31c5c6a21d58e0f27f93df812e49e0e98
-session-projection.zh.md: 044351647a9e16df921d8a41478f58ac4f9964d0
+session-projection.md: 2a08f1a7131094b6049ecd9f5d43df448bc6dbf3
+session-projection.zh.md: 73c1f02b897f5ac5408e3199096876d3ba438224
diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md
index 52a745b31c..2a08f1a713 100644
--- a/docs/subsystems/session-projection.md
+++ b/docs/subsystems/session-projection.md
@@ -98,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (both views are computed in the driving step; no stored comparison value exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
+`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (views are memoized by state object identity — one computation per distinct state, and no last-delivered record exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
## The registry: `ctx.sessionProjections`
@@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md
index 044351647a..73c1f02b89 100644
--- a/docs/subsystems/session-projection.zh.md
+++ b/docs/subsystems/session-projection.zh.md
@@ -98,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(两侧视图都在驱动当步现算,不存比较值故无从过期),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
+`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(视图按状态对象身份做备忘——每个不同状态只算一次,且不存在会过期的「上次交付」记录),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
## 注册表:`ctx.sessionProjections`
@@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 20926bcafe..0df4db8791 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'sessionProjections',
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
- description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
+ description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state\'s view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
methods: [
{
signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void',
diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml
index 3fe3d649a0..571c63775f 100644
--- a/packages/session/session-projection/README.i18n.yaml
+++ b/packages/session/session-projection/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
-README.md: 753b83c2f8e7fa7ec9bf40b6107f9067c27f8362
-README.zh.md: dd036b4553f406e1cbe1b9f9280c8c682da8d1be
+README.md: 4ea86cb187f1081978be97ec0094977ef5e6778e
+README.zh.md: ab051795bdcb0547e817063bd91374954d79cb0d
diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md
index 753b83c2f8..4ea86cb187 100644
--- a/packages/session/session-projection/README.md
+++ b/packages/session/session-projection/README.md
@@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable
### Design concept
-The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
+The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
### Source map
diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md
index dd036b4553..ab051795bd 100644
--- a/packages/session/session-projection/README.zh.md
+++ b/packages/session/session-projection/README.zh.md
@@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
### 设计理念
-本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——两侧视图都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
+本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——视图按状态对象身份做备忘,每个不同状态的视图只计算一次,且不存在会在监听器换代期间过期的「上次交付」记录(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
### 源码地图
diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts
index 0af012beea..27e2e389cc 100644
--- a/packages/session/session-projection/src/index.ts
+++ b/packages/session/session-projection/src/index.ts
@@ -160,6 +160,13 @@ interface UnitCell {
interface Registration {
readonly def: ErasedDefinition
readonly cells: WeakMap
+ /**
+ * Raw `view` output per state object (pure-view memo). An entry is the
+ * view of that exact state — not a last-delivered record — so it cannot go
+ * stale; a missing entry recomputes. Weak keys die with their states;
+ * primitive states bypass the memo.
+ */
+ readonly viewMemo: WeakMap
/** Live registrants sharing this unit; the last one out removes the key. */
refs: number
}
@@ -170,9 +177,10 @@ interface Registration {
* every registered unit's `apply` (eager drive), and a changed state
* reference in a client-visible unit notifies the change feed with the
* schema-validated view — unless the raw view output is `Object.is`-identical
- * to the unit's previous projection (identity-stable projections stay quiet;
- * both views are computed from the states in hand each step, so no stored
- * comparison value exists to go stale across listener generations).
+ * to the unit's previous projection (identity-stable projections stay quiet).
+ * Views are memoized by state object identity, so each distinct state's view
+ * computes once and no last-delivered record exists to go stale across
+ * listener generations.
* Cells build lazily — a unit registered after events flowed, or a session
* older than the registry, folds `init` over the in-memory log on first
* touch (event or read). Registration is an effect (disposer rides the
@@ -262,7 +270,7 @@ export class SessionProjectionRegistry extends Service {
const key = erased.key
const existing = this.registrations.get(key)
if (existing === undefined) {
- this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 })
+ this.registrations.set(key, { def: erased, cells: new WeakMap(), viewMemo: new WeakMap(), refs: 1 })
} else {
if (existing.def.stateVersion !== erased.stateVersion) {
throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`)
@@ -636,13 +644,13 @@ export class SessionProjectionRegistry extends Service {
cell.state = next
cell.observedSeq = event.seq
if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
- // Identity gate on the raw view, computed from the two states in
- // hand: a changed state whose projection is reference-identical to
- // the previous state's stays quiet, so a unit can buffer working
- // fields without spamming the feed. Nothing is stored, so no
- // comparison value exists to go stale across listener generations.
- const raw = registration.def.wire.view(next)
- if (Object.is(registration.def.wire.view(previous), raw)) continue
+ // Identity gate on the raw view, memoized by state identity: the
+ // previous state's view was cached when that state was current, so
+ // each distinct state's view computes once and the quiet path
+ // allocates nothing. The memo cannot go stale — an entry is the view
+ // of that exact state, not a record of what the feed last delivered.
+ const raw = this.viewOf(registration.def.wire, registration.viewMemo, next)
+ if (Object.is(this.viewOf(registration.def.wire, registration.viewMemo, previous), raw)) continue
const value = registration.def.wire.viewSchema.parse(raw)
for (const listener of this.listeners) {
listener(session, registration.def.key as Extract, value, event.seq)
@@ -655,7 +663,22 @@ export class SessionProjectionRegistry extends Service {
private viewCell(registration: Registration, cell: UnitCell): unknown {
const wire = registration.def.wire
if (wire === undefined) throw new Error(`session projection ${JSON.stringify(registration.def.key)} has no wire view`)
- return wire.viewSchema.parse(wire.view(cell.state))
+ return wire.viewSchema.parse(this.viewOf(wire, registration.viewMemo, cell.state))
+ }
+
+ /**
+ * One unit's raw `view` output for one state, memoized by state object
+ * identity (the pure-view contract makes the entry permanently correct).
+ * Primitive states have no WeakMap key and compute directly.
+ * @param wire - the unit's wire block.
+ * @param memo - the unit's per-state view memo.
+ * @param state - a state produced by the unit's `init`/`apply`.
+ * @returns the raw (pre-validation) `view` output for that state.
+ */
+ private viewOf(wire: NonNullable, memo: WeakMap, state: unknown): unknown {
+ if (typeof state !== 'object' || state === null) return wire.view(state)
+ if (!memo.has(state)) memo.set(state, wire.view(state))
+ return memo.get(state)
}
}
diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts
index 5ab26d4458..ef12ef6a14 100644
--- a/packages/session/session-projection/tests/registry.spec.ts
+++ b/packages/session/session-projection/tests/registry.spec.ts
@@ -151,6 +151,45 @@ describe('SessionProjectionRegistry drive', () => {
expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b'])
})
+ it("computes each distinct state's view once: the memo serves previous states to the gate and snapshots", async () => {
+ const { ctx, session } = await harness()
+ let viewCalls = 0
+ ctx.sessionProjections.register({
+ key: 'test/buffered',
+ stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }),
+ init: () => ({ marks: [], draft: '' }),
+ apply: (state, event) => {
+ if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' }
+ if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` }
+ return state
+ },
+ wire: {
+ viewSchema: z.array(z.string()),
+ view: (state) => {
+ viewCalls += 1
+ return state.marks
+ },
+ },
+ stateVersion: 1,
+ })
+ const seen: unknown[] = []
+ ctx.sessionProjections.onChanged((_session, key, value) => {
+ if (key === 'test/buffered') seen.push(value)
+ })
+ // First change touches two never-seen states (init and next): two calls.
+ mark(session, ['a'])
+ expect(viewCalls).toBe(2)
+ // Draft-only change: the new state computes, the previous is a memo hit.
+ session.append('turn/start', { turn: 1 })
+ expect(viewCalls).toBe(3)
+ mark(session, ['a', 'b'])
+ expect(viewCalls).toBe(4)
+ expect(seen).toEqual([['a'], ['a', 'b']])
+ // Snapshot reads reuse the same memo instead of recomputing the view.
+ expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b'])
+ expect(viewCalls).toBe(4)
+ })
+
it('keeps dedup honest across listener generations: a return to an old value after an unobserved change still fires', async () => {
const { ctx, session } = await harness()
// A primitive-valued view compares by value under Object.is (the title
From 6f0daff1dd09c32305371ed2f66bae9a6fc96ae7 Mon Sep 17 00:00:00 2001
From: imccyu
Date: Mon, 31 Aug 2026 19:59:54 +0800
Subject: [PATCH 25/26] fix(session-projection): compare observed live views
---
...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +-
.../2026-08-30-web-turn-rail-outline-jump.md | 2 +-
...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +-
docs/subsystems/session-projection.i18n.yaml | 4 +-
docs/subsystems/session-projection.md | 20 +-
docs/subsystems/session-projection.zh.md | 20 +-
.../tests/session-projections.host.spec.ts | 3 +-
.../extensions/tool-cordis/src/api-catalog.ts | 4 +-
.../session-projection/README.i18n.yaml | 4 +-
packages/session/session-projection/README.md | 8 +-
.../session/session-projection/README.zh.md | 8 +-
.../session/session-projection/src/index.ts | 97 ++++------
.../session-projection/src/invariant.ts | 4 +-
.../session-projection/tests/registry.spec.ts | 176 ++++++++----------
14 files changed, 160 insertions(+), 196 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
index 36a5d28b87..935dd280d4 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
-2026-08-30-web-turn-rail-outline-jump.md: c1f190832ebfb83200d8f7cb5c01b9a45167ccab
-2026-08-30-web-turn-rail-outline-jump.zh.md: 66196430ce095e822a5e05863f591ea5cebce570
+2026-08-30-web-turn-rail-outline-jump.md: af2a464513d108429859c4f9ed8b4bfc58175e3c
+2026-08-30-web-turn-rail-outline-jump.zh.md: b63b1c1f656689997b8a8266ee332dfdb47d4035
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
index c1f190832e..af2a464513 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
@@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone.
**Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn.
-**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; views are memoized by state object identity (review moved this off a stored last-delivered value, then off per-step recomputation), so each distinct state's view computes once and the memo — keyed by the state itself, not by what the feed delivered — cannot go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
+**Change-feed identity gate (session-projection).** Each live unit cell keeps `[previousView, currentView]` raw outputs. When a state reference changes, the drive shifts current to previous; while a change listener exists it computes `view(nextState)` once, stores current, and emits only when the two outputs differ by `Object.is`. Without listeners, current becomes `undefined` without evaluating `view`, so the first later computed value publishes conservatively; catch-up folding invalidates current in the same way. This lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — is rejected because every quiet change would pay for full serialization.
**Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
index 66196430ce..b63b1c1f65 100644
--- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md
@@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口
**数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。
-**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;视图按状态对象身份做备忘(评审先从「存上一次交付值」改为现算、再改为按状态键缓存),每个不同状态只算一次,备忘的键是状态本身而非交付历史,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
+**变更流身份门(session-projection)。** 每个实时单元 cell 保存 `[previousView, currentView]` 原始输出。state 引用变化时,drive 先把 current 移到 previous;存在变更 listener 时只计算一次 `view(nextState)` 并写入 current,两个输出通过 `Object.is` 判定为不同时才发出通知。没有 listener 时不计算 `view`,而是把 current 写成 `undefined`,因此之后首次计算出的值会保守地发布;补折叠也以相同方式使 current 失效。这让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的 state 里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
**分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml
index 0f80f31420..1cad3ee1f0 100644
--- a/docs/subsystems/session-projection.i18n.yaml
+++ b/docs/subsystems/session-projection.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: 2a08f1a7131094b6049ecd9f5d43df448bc6dbf3
-session-projection.zh.md: 73c1f02b897f5ac5408e3199096876d3ba438224
+session-projection.md: b5bacc4846a9a9709bb8c102aaa78810a2752110
+session-projection.zh.md: 36653721a125ceef13bff8b1a4562e7981a45696
diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md
index 2a08f1a713..b5bacc4846 100644
--- a/docs/subsystems/session-projection.md
+++ b/docs/subsystems/session-projection.md
@@ -47,7 +47,10 @@ interface ProjectionDefinition<
/** Validates the wire payload before it leaves the host. */
viewSchema: ZodType
/**
- * State → wire payload (the read-side projection).
+ * State → wire payload (the read-side projection). The live drive keeps
+ * the two latest raw results and compares them with `Object.is`; an
+ * object-valued view must reuse its reference to suppress publication
+ * across internal-only state changes.
* @param state - the current state.
* @returns the whole current value for this unit's key.
*/
@@ -83,12 +86,9 @@ interface ProjectionSnapshot {
```ts type-equiv
/**
- * Change-feed listener: one unit's served value changed for one session.
- * `value` is the schema-validated `view` output; `seq` is the unit's
- * watermark at emission (the seq of the event that caused the change). A
- * changed state whose raw `view` output is `Object.is`-identical to the
- * unit's previous projection does not fire, so a unit can buffer working
- * fields in state behind an identity-stable projection.
+ * Change-feed listener: one unit's raw `view` result changed by `Object.is`
+ * for one session. `value` is the schema-validated output; `seq` is the
+ * unit's watermark at emission (the seq of the event that caused the change).
*/
type ProjectionChangeListener = (
session: Session,
@@ -98,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (views are memoized by state object identity — one computation per distinct state, and no last-delivered record exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
+`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes.
## The registry: `ctx.sessionProjections`
@@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
@@ -204,7 +204,7 @@ register< K extends Exclude void
diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md
index 73c1f02b89..36653721a1 100644
--- a/docs/subsystems/session-projection.zh.md
+++ b/docs/subsystems/session-projection.zh.md
@@ -47,7 +47,10 @@ interface ProjectionDefinition<
/** Validates the wire payload before it leaves the host. */
viewSchema: ZodType
/**
- * State → wire payload (the read-side projection).
+ * State → wire payload (the read-side projection). The live drive keeps
+ * the two latest raw results and compares them with `Object.is`; an
+ * object-valued view must reuse its reference to suppress publication
+ * across internal-only state changes.
* @param state - the current state.
* @returns the whole current value for this unit's key.
*/
@@ -83,12 +86,9 @@ interface ProjectionSnapshot {
```ts type-equiv
/**
- * Change-feed listener: one unit's served value changed for one session.
- * `value` is the schema-validated `view` output; `seq` is the unit's
- * watermark at emission (the seq of the event that caused the change). A
- * changed state whose raw `view` output is `Object.is`-identical to the
- * unit's previous projection does not fire, so a unit can buffer working
- * fields in state behind an identity-stable projection.
+ * Change-feed listener: one unit's raw `view` result changed by `Object.is`
+ * for one session. `value` is the schema-validated output; `seq` is the
+ * unit's watermark at emission (the seq of the event that caused the change).
*/
type ProjectionChangeListener = (
session: Session,
@@ -98,7 +98,7 @@ type ProjectionChangeListener = (
) => void
```
-`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(视图按状态对象身份做备忘——每个不同状态只算一次,且不存在会过期的「上次交付」记录),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
+`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。
## 注册表:`ctx.sessionProjections`
@@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
### `ctx.sessionProjections` — `SessionProjectionRegistry`
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
@@ -204,7 +204,7 @@ register< K extends Exclude void
diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts
index 8a31e9208a..0718f7d657 100644
--- a/packages/api/session-controller/tests/session-projections.host.spec.ts
+++ b/packages/api/session-controller/tests/session-projections.host.spec.ts
@@ -538,7 +538,7 @@ describe('Session control projection frames', () => {
return frames
}
- it('broadcasts a frame per changed unit with the causing seq, and none for same-reference applies', async () => {
+ it('broadcasts changed view references with the causing seq and skips same-reference applies', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
const proxy = remote(ctx)
@@ -554,6 +554,7 @@ describe('Session control projection frames', () => {
now.mockReturnValue(200)
session.append('turn/start', { turn: 1 })
now.mockReturnValue(300)
+ // The equal payload is a new object, so Object.is still treats its view as changed.
seedMessages(session, 1)
now.mockRestore()
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 0df4db8791..c8c597a983 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'sessionProjections',
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
- description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state\'s view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
+ description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
methods: [
{
signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void',
@@ -1591,7 +1591,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
signature: 'onChanged(listener: ProjectionChangeListener): () => void',
description: 'Subscribe to the change feed. The registration is an effect on the calling context\'s fiber.',
- parameters: [{ name: 'listener', description: 'called once per client-visible unit whose state reference changed, per committed event.' }],
+ parameters: [{ name: 'listener', description: 'called once per client-visible unit whose raw view changed by `Object.is`, per committed event.' }],
returns: 'the exact disposer that unsubscribes.',
},
{
diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml
index 571c63775f..5e8356ad31 100644
--- a/packages/session/session-projection/README.i18n.yaml
+++ b/packages/session/session-projection/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
-README.md: 4ea86cb187f1081978be97ec0094977ef5e6778e
-README.zh.md: ab051795bdcb0547e817063bd91374954d79cb0d
+README.md: 2ef81c00b283e76d0553c85f1ce5acd12ab863da
+README.zh.md: 996c51887689eed95a5cabee8cf89e6433575647
diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md
index 4ea86cb187..2ef81c00b2 100644
--- a/packages/session/session-projection/README.md
+++ b/packages/session/session-projection/README.md
@@ -51,7 +51,7 @@ const definition = {
}
```
-`apply` must be synchronous and must return the same state reference for events that do not concern the unit — an unchanged reference means zero downstream work. A state-carrying log event must carry the complete post-change state, never a bare delta.
+`apply` must be synchronous and must return the same state reference for events that do not concern the unit — an unchanged reference means zero downstream work. The registry compares consecutive raw `wire.view` results with `Object.is`; an object or array view must reuse its reference to suppress publication across internal-only state changes, while a structurally equal new object is still a change. A state-carrying log event must carry the complete post-change state, never a bare delta.
### Register and read
@@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable
### Design concept
-The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
+The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The first `Object.is` gate skips view work when the state reference is unchanged; a two-slot live-drive cache reuses the previous raw view and a second `Object.is` gate suppresses publication while the raw view reference is unchanged. Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
### Source map
@@ -90,7 +90,7 @@ The package is the Service Definition and drive role of a capability seam: the f
### Drive and checkpoint flow
-One committed event drives every registered unit in registration order; a changed client-visible unit notifies the change feed with its schema-validated view and the causing seq. `checkpoint(session)` returns one detached `(key → {ver, seq, val})` row per unit for the persisted cache; `restoreFloor` anchors a tail read one event below the lowest usable watermark so a shrunk log is detected, and `restore` refolds persisted rows over a stored suffix, discarding any row whose `ver` does not match or that claims events past the stored end.
+One committed event drives every registered unit in registration order; a client-visible unit whose raw view changes by `Object.is` notifies the change feed with its schema-validated view and the causing seq. The live drive retains its previous and current raw views; snapshots and cold reads remain complete independent reads. `checkpoint(session)` returns one detached `(key → {ver, seq, val})` row per unit for the persisted cache; `restoreFloor` anchors a tail read one event below the lowest usable watermark so a shrunk log is detected, and `restore` refolds persisted rows over a stored suffix, discarding any row whose `ver` does not match or that claims events past the stored end.
@@ -127,7 +127,7 @@ These limits define where the projection registry needs care at scale. They are
- **Every tail page carries every client-visible key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states, revisit if a domain's value grows large.
- **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by any agent preset appears in every session's snapshot; a client must read the value rather than treat an absent key as absence of the feature.
-- **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters.
+- **Eager drive touches every unit per event** — cheap by construction (whole-value rule and state/view reference gates), but a hot path would justify per-unit event-type prefilters.
- **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead.
- **Synchronous unit discipline is only partially mechanical** — `wire.viewSchema.parse` rejects a Promise-returning view, but an `apply` that blocks or reads torn non-session state is a review concern.
diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md
index ab051795bd..996c518876 100644
--- a/packages/session/session-projection/README.zh.md
+++ b/packages/session/session-projection/README.zh.md
@@ -51,7 +51,7 @@ const definition = {
}
```
-`apply` 必须同步,且对与单元无关的事件必须返回同一个状态引用——引用不变意味着零下游工作。携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量。
+`apply` 必须同步,且对与单元无关的事件必须返回同一个状态引用——引用不变意味着零下游工作。注册表用 `Object.is` 比较相邻的 `wire.view` 原始结果;对象或数组 view 若要在仅内部 state 变化时抑制发布,就必须复用引用,结构相同的新对象仍算变化。携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量。
### 注册与读取
@@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
### 设计理念
-本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——视图按状态对象身份做备忘,每个不同状态的视图只计算一次,且不存在会在监听器换代期间过期的「上次交付」记录(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
+本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。第一层 `Object.is` 闸门在 state 引用不变时跳过 view 工作;live drive 的双槽缓存复用前一个原始 view,第二层 `Object.is` 闸门在原始 view 引用不变时抑制发布。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
### 源码地图
@@ -90,7 +90,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
### 驱动与检查点流程
-一个已提交事件按注册顺序驱动每个已注册单元;状态引用变化的客户端可见单元会以经 schema 校验的视图与致因 seq 通知变更流。`checkpoint(session)` 为持久缓存返回每个单元一份独立的 `(key → {ver, seq, val})` 行;`restoreFloor` 把尾部读取锚定在最低可用水位之前一个事件处,使缩短的日志可被检出;`restore` 把持久行在存储后缀上重新折叠,丢弃任何 `ver` 不匹配或声称越过存储末尾的行。
+一个已提交事件按注册顺序驱动每个已注册单元;原始 view 通过 `Object.is` 判定为变化的客户端可见单元会以经 schema 校验的视图与致因 seq 通知变更流。live drive 保留前后两个原始 view;snapshot 与冷读仍是彼此独立的完整读取。`checkpoint(session)` 为持久缓存返回每个单元一份独立的 `(key → {ver, seq, val})` 行;`restoreFloor` 把尾部读取锚定在最低可用水位之前一个事件处,使缩短的日志可被检出;`restore` 把持久行在存储后缀上重新折叠,丢弃任何 `ver` 不匹配或声称越过存储末尾的行。
@@ -127,7 +127,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
- **每个尾页携带每个 client-visible key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态时可以接受,若某领域的值变大再重议。
- **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——任何 agent preset 注册的 key 都会出现在每个会话的快照里;客户端必须读值,不能把 key 缺席当作功能缺席。
-- **主动驱动逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤。
+- **主动驱动逐事件触达每个单元**——按构造开销很低(全量值规则与 state/view 引用闸门),但若出现热点路径,可加按单元的事件类型预过滤。
- **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。
- **单元同步纪律只有部分可机械把关**——`wire.viewSchema.parse` 能拒绝返回 Promise 的 view,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关。
diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts
index 27e2e389cc..2910eee9be 100644
--- a/packages/session/session-projection/src/index.ts
+++ b/packages/session/session-projection/src/index.ts
@@ -67,7 +67,10 @@ export interface ProjectionDefinition<
/** Validates the wire payload before it leaves the host. */
viewSchema: ZodType
/**
- * State → wire payload (the read-side projection).
+ * State → wire payload (the read-side projection). The live drive keeps
+ * the two latest raw results and compares them with `Object.is`; an
+ * object-valued view must reuse its reference to suppress publication
+ * across internal-only state changes.
* @param state - the current state.
* @returns the whole current value for this unit's key.
*/
@@ -83,12 +86,9 @@ export interface ProjectionDefinition<
}
/**
- * Change-feed listener: one unit's served value changed for one session.
- * `value` is the schema-validated `view` output; `seq` is the unit's
- * watermark at emission (the seq of the event that caused the change). A
- * changed state whose raw `view` output is `Object.is`-identical to the
- * unit's previous projection does not fire, so a unit can buffer working
- * fields in state behind an identity-stable projection.
+ * Change-feed listener: one unit's raw `view` result changed by `Object.is`
+ * for one session. `value` is the schema-validated output; `seq` is the
+ * unit's watermark at emission (the seq of the event that caused the change).
*/
export type ProjectionChangeListener = (
session: Session,
@@ -139,11 +139,13 @@ interface ErasedDefinition {
stateVersion: number
}
-/** Per-session per-unit watermark cache row. */
+/** Per-session per-unit watermark and fixed live-drive view buffer. */
interface UnitCell {
state: unknown
/** Seq of the last event passed through `apply` (regardless of change). */
observedSeq: number
+ /** `[previousView, currentView]`; undefined slots mean no cached comparison. */
+ readonly views: [unknown, unknown]
}
/**
@@ -160,13 +162,6 @@ interface UnitCell {
interface Registration {
readonly def: ErasedDefinition
readonly cells: WeakMap
- /**
- * Raw `view` output per state object (pure-view memo). An entry is the
- * view of that exact state — not a last-delivered record — so it cannot go
- * stale; a missing entry recomputes. Weak keys die with their states;
- * primitive states bypass the memo.
- */
- readonly viewMemo: WeakMap
/** Live registrants sharing this unit; the last one out removes the key. */
refs: number
}
@@ -174,13 +169,9 @@ interface Registration {
/**
* `ctx.sessionProjections`: the projection unit table and its drive. The
* service subscribes to `session/event` once; every committed event passes
- * every registered unit's `apply` (eager drive), and a changed state
- * reference in a client-visible unit notifies the change feed with the
- * schema-validated view — unless the raw view output is `Object.is`-identical
- * to the unit's previous projection (identity-stable projections stay quiet).
- * Views are memoized by state object identity, so each distinct state's view
- * computes once and no last-delivered record exists to go stale across
- * listener generations.
+ * every registered unit's `apply` (eager drive). A changed state reference
+ * computes the next client view; the change feed is notified only when its
+ * raw result changes by `Object.is`.
* Cells build lazily — a unit registered after events flowed, or a session
* older than the registry, folds `init` over the in-memory log on first
* touch (event or read). Registration is an effect (disposer rides the
@@ -210,6 +201,7 @@ export class SessionProjectionRegistry extends Service {
registration.cells.set(session, {
state: registration.def.init(session.header),
observedSeq: -1,
+ views: [undefined, undefined],
})
}
})
@@ -270,7 +262,7 @@ export class SessionProjectionRegistry extends Service {
const key = erased.key
const existing = this.registrations.get(key)
if (existing === undefined) {
- this.registrations.set(key, { def: erased, cells: new WeakMap(), viewMemo: new WeakMap(), refs: 1 })
+ this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 })
} else {
if (existing.def.stateVersion !== erased.stateVersion) {
throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`)
@@ -291,7 +283,7 @@ export class SessionProjectionRegistry extends Service {
/**
* Subscribe to the change feed. The registration is an effect on the
* calling context's fiber.
- * @param listener - called once per client-visible unit whose state reference changed, per committed event.
+ * @param listener - called once per client-visible unit whose raw view changed by `Object.is`, per committed event.
* @returns the exact disposer that unsubscribes.
*/
onChanged(listener: ProjectionChangeListener): () => void {
@@ -573,6 +565,7 @@ export class SessionProjectionRegistry extends Service {
registration.cells.set(session, {
state: row.val,
observedSeq: row.seq,
+ views: [undefined, undefined],
})
}
return restored.snapshot
@@ -591,7 +584,7 @@ export class SessionProjectionRegistry extends Service {
): UnitCell {
let state = def.init(header)
for (const event of events) state = def.apply(state, event)
- return { state, observedSeq: (events.at(-1)?.seq ?? -1) }
+ return { state, observedSeq: (events.at(-1)?.seq ?? -1), views: [undefined, undefined] }
}
/** Read (or lazily build, folding the full in-memory log) one unit's cell. */
@@ -620,12 +613,16 @@ export class SessionProjectionRegistry extends Service {
throw new Error(`session projection ${JSON.stringify(def.key)} cannot advance across missing seq ${String(seq)}`)
}
const next = def.apply(cell.state, event)
+ if (!Object.is(next, cell.state)) {
+ cell.views[0] = cell.views[1]
+ cell.views[1] = undefined
+ }
cell.state = next
cell.observedSeq = seq
}
}
- /** Eager drive: pass one committed event through every registered unit; notify on changed references. */
+ /** Eager drive: pass one committed event through every unit; notify on changed raw view references. */
private drive(session: Session, event: SessionEvent): void {
for (const registration of this.registrations.values()) {
let cell = registration.cells.get(session)
@@ -638,22 +635,25 @@ export class SessionProjectionRegistry extends Service {
} else {
this.advanceCell(registration.def, cell, session.events, event.seq - 1)
}
- const previous = cell.state
- const next = registration.def.apply(previous, event)
- const changed = !Object.is(next, previous)
+ const previousState = cell.state
+ const next = registration.def.apply(previousState, event)
+ const changed = !Object.is(next, previousState)
cell.state = next
cell.observedSeq = event.seq
- if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
- // Identity gate on the raw view, memoized by state identity: the
- // previous state's view was cached when that state was current, so
- // each distinct state's view computes once and the quiet path
- // allocates nothing. The memo cannot go stale — an entry is the view
- // of that exact state, not a record of what the feed last delivered.
- const raw = this.viewOf(registration.def.wire, registration.viewMemo, next)
- if (Object.is(this.viewOf(registration.def.wire, registration.viewMemo, previous), raw)) continue
- const value = registration.def.wire.viewSchema.parse(raw)
- for (const listener of this.listeners) {
- listener(session, registration.def.key as Extract, value, event.seq)
+ const wire = registration.def.wire
+ if (changed && wire !== undefined) {
+ const views = cell.views
+ views[0] = views[1]
+ if (this.listeners.size > 0) {
+ views[1] = wire.view(next)
+ if (!Object.is(views[0], views[1])) {
+ const value = wire.viewSchema.parse(views[1])
+ for (const listener of this.listeners) {
+ listener(session, registration.def.key as Extract, value, event.seq)
+ }
+ }
+ } else {
+ views[1] = undefined
}
}
}
@@ -663,22 +663,7 @@ export class SessionProjectionRegistry extends Service {
private viewCell(registration: Registration, cell: UnitCell): unknown {
const wire = registration.def.wire
if (wire === undefined) throw new Error(`session projection ${JSON.stringify(registration.def.key)} has no wire view`)
- return wire.viewSchema.parse(this.viewOf(wire, registration.viewMemo, cell.state))
- }
-
- /**
- * One unit's raw `view` output for one state, memoized by state object
- * identity (the pure-view contract makes the entry permanently correct).
- * Primitive states have no WeakMap key and compute directly.
- * @param wire - the unit's wire block.
- * @param memo - the unit's per-state view memo.
- * @param state - a state produced by the unit's `init`/`apply`.
- * @returns the raw (pre-validation) `view` output for that state.
- */
- private viewOf(wire: NonNullable, memo: WeakMap, state: unknown): unknown {
- if (typeof state !== 'object' || state === null) return wire.view(state)
- if (!memo.has(state)) memo.set(state, wire.view(state))
- return memo.get(state)
+ return wire.viewSchema.parse(wire.view(cell.state))
}
}
diff --git a/packages/session/session-projection/src/invariant.ts b/packages/session/session-projection/src/invariant.ts
index 537015d932..8d177bf835 100644
--- a/packages/session/session-projection/src/invariant.ts
+++ b/packages/session/session-projection/src/invariant.ts
@@ -16,8 +16,8 @@ export const inject = ['invariants']
/**
* No runtime invariant: the registry's own contracts (duplicate-key and
- * stateVersion rejection, effect-tied removal, the Object.is change gate) are
- * enforced synchronously inside the service and proven by its spec, the
+ * stateVersion rejection, effect-tied removal, and the state/view `Object.is`
+ * gates) are enforced synchronously inside the service and proven by its spec, the
* drive relation (every committed `session/event` passes every unit) would
* require re-running the drive to check — duplicating the implementation
* rather than detecting drift — and the served-value relation (every served
diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts
index ef12ef6a14..19c6419d28 100644
--- a/packages/session/session-projection/tests/registry.spec.ts
+++ b/packages/session/session-projection/tests/registry.spec.ts
@@ -1,13 +1,13 @@
/**
* SessionProjectionRegistry unit drive: eager apply on committed events with
* lazy cell build (registration after events, session after registration),
- * the Object.is no-change gate (same reference ⇒ zero change-feed work),
- * snapshot consistency (asOfSeq = last event seq; values from the watermark
- * cache), duplicate-key rejection, stateVersion validation, and effect-tied
- * removal of registrations and change listeners (HMR safety).
+ * the Object.is no-change gates (same state or raw view reference ⇒ zero
+ * change-feed work), snapshot consistency (asOfSeq = last event seq; values
+ * from the watermark cache), duplicate-key rejection, stateVersion validation,
+ * and effect-tied removal of registrations and change listeners (HMR safety).
*/
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -19,14 +19,12 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
'test/marks': MarksState
'test/count': number
- 'test/buffered': { marks: string[]; draft: string }
- 'test/label': string
+ 'test/stable-view': StableViewState
}
interface SessionProjectionMap {
'test/marks': { marks: string[] }
- 'test/buffered': string[]
- 'test/label': string
+ 'test/stable-view': { marks: string[] }
}
}
@@ -36,7 +34,15 @@ declare module '@deepseek-ai/dsh-session/types' {
}
}
-type MarksState = { marks: string[] } | null
+interface MarksView {
+ marks: string[]
+}
+type MarksState = MarksView | null
+interface StableViewState {
+ revision: number
+ value: MarksView
+}
+const marksViewSchema: z.ZodType = z.object({ marks: z.array(z.string()) })
const RESTORE_HEADER: SessionHeader = {
version: 0,
id: SessionId('projection-restore'),
@@ -46,11 +52,11 @@ const RESTORE_HEADER: SessionHeader = {
const marksUnit = (): Omit, 'wire'>
& { wire: NonNullable['wire']> } => ({
key: 'test/marks',
- stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
+ stateSchema: marksViewSchema.nullable(),
init: () => null,
apply: (state, event) => (event.type === 'test/mark' ? (event).data : state),
wire: {
- viewSchema: z.object({ marks: z.array(z.string()) }),
+ viewSchema: marksViewSchema,
view: state => state ?? { marks: [] },
},
stateVersion: 1,
@@ -65,6 +71,27 @@ const countUnit = (): ProjectionDefinition<'test/count', number> => ({
stateVersion: 1,
})
+const stableViewUnit = (
+ view: (state: StableViewState) => StableViewState['value'],
+) => ({
+ key: 'test/stable-view',
+ stateSchema: z.object({
+ revision: z.number().int().nonnegative(),
+ value: marksViewSchema,
+ }),
+ init: () => ({ revision: 0, value: { marks: [] } }),
+ apply: (state, event) => {
+ if (event.type === 'turn/start') return { ...state, revision: state.revision + 1 }
+ if (event.type === 'test/mark') return { revision: state.revision + 1, value: event.data }
+ return state
+ },
+ wire: {
+ viewSchema: marksViewSchema,
+ view,
+ },
+ stateVersion: 1,
+}) satisfies ProjectionDefinition<'test/stable-view', StableViewState>
+
async function harness(): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -117,111 +144,62 @@ describe('SessionProjectionRegistry drive', () => {
expect(seen).toEqual([{ key: 'test/marks', value: { marks: ['a'] }, seq: event.seq, sessionId: String(session.id) }])
})
- it('keeps the feed quiet while a changed state serves an identity-stable view (draft buffering)', async () => {
+ it('does not compute a view while no change listener exists', async () => {
const { ctx, session } = await harness()
- // Unit buffering a working field beside its wire array: the view projects
- // only `marks`, whose identity survives draft-only applies.
- ctx.sessionProjections.register({
- key: 'test/buffered',
- stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }),
- init: () => ({ marks: [], draft: '' }),
- apply: (state, event) => {
- if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' }
- if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` }
- return state
- },
- wire: { viewSchema: z.array(z.string()), view: state => state.marks },
- stateVersion: 1,
- })
- const seen: { value: unknown; seq: number }[] = []
- ctx.sessionProjections.onChanged((_session, key, value, seq) => {
- if (key === 'test/buffered') seen.push({ value, seq })
- })
- const first = mark(session, ['a'])
- // Draft-only applies change the state reference but not the served view.
+ const view = vi.fn((state: StableViewState) => state.value)
+ ctx.sessionProjections.register(stableViewUnit(view))
+
session.append('turn/start', { turn: 1 })
session.append('turn/start', { turn: 2 })
- const second = mark(session, ['a', 'b'])
- expect(seen).toEqual([
- { value: ['a'], seq: first.seq },
- { value: ['a', 'b'], seq: second.seq },
- ])
- // The quiet applies still advanced the state itself.
- expect(ctx.sessionProjections.stateOf(session, 'test/buffered')?.draft).toBe('')
- expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b'])
+
+ expect(ctx.sessionProjections.stateOf(session, 'test/stable-view')?.revision).toBe(2)
+ expect(view).not.toHaveBeenCalled()
})
- it("computes each distinct state's view once: the memo serves previous states to the gate and snapshots", async () => {
+ it('publishes the first observed view and suppresses later same-reference views', async () => {
const { ctx, session } = await harness()
- let viewCalls = 0
- ctx.sessionProjections.register({
- key: 'test/buffered',
- stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }),
- init: () => ({ marks: [], draft: '' }),
- apply: (state, event) => {
- if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' }
- if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` }
- return state
- },
- wire: {
- viewSchema: z.array(z.string()),
- view: (state) => {
- viewCalls += 1
- return state.marks
- },
- },
- stateVersion: 1,
- })
+ const view = vi.fn((state: StableViewState) => state.value)
+ ctx.sessionProjections.register(stableViewUnit(view))
+
const seen: unknown[] = []
ctx.sessionProjections.onChanged((_session, key, value) => {
- if (key === 'test/buffered') seen.push(value)
+ if (key === 'test/stable-view') seen.push(value)
})
- // First change touches two never-seen states (init and next): two calls.
- mark(session, ['a'])
- expect(viewCalls).toBe(2)
- // Draft-only change: the new state computes, the previous is a memo hit.
+
session.append('turn/start', { turn: 1 })
- expect(viewCalls).toBe(3)
- mark(session, ['a', 'b'])
- expect(viewCalls).toBe(4)
- expect(seen).toEqual([['a'], ['a', 'b']])
- // Snapshot reads reuse the same memo instead of recomputing the view.
- expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b'])
- expect(viewCalls).toBe(4)
+ session.append('turn/start', { turn: 2 })
+
+ expect(seen).toEqual([{ marks: [] }])
+ expect(view).toHaveBeenCalledTimes(2)
+
+ mark(session, ['changed'])
+ expect(seen).toEqual([{ marks: [] }, { marks: ['changed'] }])
+ expect(view).toHaveBeenCalledTimes(3)
})
- it('keeps dedup honest across listener generations: a return to an old value after an unobserved change still fires', async () => {
+ it('publishes the first view after an unobserved state change', async () => {
const { ctx, session } = await harness()
- // A primitive-valued view compares by value under Object.is (the title
- // unit's shape), which is exactly where remembering a delivered value —
- // instead of comparing the two states in hand — would silence a real
- // transition.
- ctx.sessionProjections.register({
- key: 'test/label',
- stateSchema: z.string(),
- init: () => '',
- apply: (state, event) => (event.type === 'test/mark' ? event.data.marks[0] ?? '' : state),
- wire: { viewSchema: z.string(), view: state => state },
- stateVersion: 1,
- })
- const first: string[] = []
+ const view = vi.fn((state: StableViewState) => state.value)
+ ctx.sessionProjections.register(stableViewUnit(view))
+ const first: unknown[] = []
const stop = ctx.sessionProjections.onChanged((_session, key, value) => {
- if (key === 'test/label') first.push(value as string)
+ if (key === 'test/stable-view') first.push(value)
})
- mark(session, ['A'])
+
+ session.append('turn/start', { turn: 1 })
stop()
- // Unobserved transition away from 'A'…
- mark(session, ['B'])
- // …then a new listener generation subscribes and the value returns:
- // dedup memory frozen at the delivered 'A' would silence this delivery;
- // the per-step previous-state comparison sees 'B' → 'A' and fires.
- const second: string[] = []
+ session.append('turn/start', { turn: 2 })
+ expect(view).toHaveBeenCalledTimes(1)
+
+ const resumed: unknown[] = []
ctx.sessionProjections.onChanged((_session, key, value) => {
- if (key === 'test/label') second.push(value as string)
+ if (key === 'test/stable-view') resumed.push(value)
})
- mark(session, ['A'])
- expect(first).toEqual(['A'])
- expect(second).toEqual(['A'])
+ session.append('turn/start', { turn: 3 })
+
+ expect(first).toEqual([{ marks: [] }])
+ expect(resumed).toEqual([{ marks: [] }])
+ expect(view).toHaveBeenCalledTimes(2)
})
it('drives independently per session (cells are per-session watermarks)', async () => {
From 9ba9a35c7255ae846f4d930c9705257875e958b3 Mon Sep 17 00:00:00 2001
From: imccyu
Date: Mon, 31 Aug 2026 20:29:08 +0800
Subject: [PATCH 26/26] test(session-projection): cover view transition matrix
---
.../session/session-projection/src/index.ts | 2 +
.../session-projection/tests/registry.spec.ts | 170 ++++++++++++++++++
2 files changed, 172 insertions(+)
diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts
index 2910eee9be..709c5f5f66 100644
--- a/packages/session/session-projection/src/index.ts
+++ b/packages/session/session-projection/src/index.ts
@@ -656,6 +656,8 @@ export class SessionProjectionRegistry extends Service {
views[1] = undefined
}
}
+ // An unchanged state keeps its current view as the valid comparison
+ // value for the next state change.
}
}
diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts
index 19c6419d28..b5e32f41c4 100644
--- a/packages/session/session-projection/tests/registry.spec.ts
+++ b/packages/session/session-projection/tests/registry.spec.ts
@@ -102,6 +102,47 @@ async function harness(): Promise<{ ctx: Context; session: Session }> {
const mark = (session: Session, marks: string[]): SessionEvent =>
session.append('test/mark', { marks })
+const STATE_SEQUENCES = [
+ [0, 0, 0, 0],
+ [0, 0, 0, 1],
+ [0, 0, 1, 0],
+ [0, 0, 1, 1],
+ [0, 0, 1, 2],
+ [0, 1, 0, 0],
+ [0, 1, 0, 1],
+ [0, 1, 0, 2],
+ [0, 1, 1, 0],
+ [0, 1, 1, 1],
+ [0, 1, 1, 2],
+ [0, 1, 2, 0],
+ [0, 1, 2, 1],
+ [0, 1, 2, 2],
+ [0, 1, 2, 3],
+] as const
+
+function identitySequences(length: number): number[][] {
+ const sequences: number[][] = []
+ const visit = (sequence: number[], highest: number): void => {
+ if (sequence.length === length) {
+ sequences.push(sequence)
+ return
+ }
+ for (let value = 0; value <= highest + 1; value++) {
+ visit([...sequence, value], Math.max(highest, value))
+ }
+ }
+ visit([0], 0)
+ return sequences
+}
+
+function sameIdentities(left: readonly unknown[], right: readonly unknown[]): boolean {
+ return left.length === right.length && left.every((value, index) => Object.is(value, right[index]))
+}
+
+function sequenceName(sequence: readonly number[], prefix: string): string {
+ return sequence.map(value => `${prefix}${String(value + 1)}`).join(',')
+}
+
describe('SessionProjectionRegistry drive', () => {
it('drives a registered unit over committed events and snapshots the current value', async () => {
const { ctx, session } = await harness()
@@ -202,6 +243,135 @@ describe('SessionProjectionRegistry drive', () => {
expect(view).toHaveBeenCalledTimes(2)
})
+ it('matches every four-state identity sequence across listener gaps and raw-view identities', async () => {
+ const { ctx } = await harness()
+ const initialState: MarksState = { marks: ['initial'] }
+ const stateByEvent = new Map()
+ const viewByState = new Map()
+ const computedViews: MarksView[] = []
+ ctx.sessionProjections.register({
+ key: 'test/marks',
+ stateSchema: marksViewSchema.nullable(),
+ init: () => initialState,
+ apply: (state, event) => {
+ if (event.type !== 'test/mark') return state
+ const token = event.data.marks[0]
+ if (token === undefined || !stateByEvent.has(token)) return state
+ return stateByEvent.get(token) as MarksState
+ },
+ wire: {
+ viewSchema: marksViewSchema,
+ view: (state) => {
+ const value = viewByState.get(state)
+ if (value === undefined) throw new Error('test state lacks a raw view')
+ computedViews.push(value)
+ return value
+ },
+ },
+ stateVersion: 1,
+ })
+
+ const failures = new Map()
+ let mismatchCount = 0
+ let checked = 0
+ for (const stateSequence of STATE_SEQUENCES) {
+ const stateCount = Math.max(...stateSequence) + 1
+ for (const viewSequence of identitySequences(stateCount)) {
+ for (const baselineKnown of [false, true]) {
+ for (let listenerMask = 0; listenerMask < 8; listenerMask++) {
+ const scenario = String(checked++)
+ const states = Array.from(
+ { length: stateCount },
+ (_, index): MarksState => ({ marks: [`state-${scenario}-${String(index)}`] }),
+ )
+ const views = Array.from(
+ { length: Math.max(...viewSequence) + 1 },
+ (): MarksView => ({ marks: [] }),
+ )
+ for (let index = 0; index < stateCount; index++) {
+ viewByState.set(states[index] as MarksState, views[viewSequence[index] as number] as MarksView)
+ }
+ for (let index = 0; index < stateSequence.length; index++) {
+ stateByEvent.set(`${scenario}:${String(index)}`, states[stateSequence[index] as number] as MarksState)
+ }
+
+ const session = ctx.sessions.create()
+ const notifications: number[] = []
+ let stop: (() => void) | undefined
+ const setListening = (listening: boolean): void => {
+ if (listening && stop === undefined) {
+ stop = ctx.sessionProjections.onChanged((changedSession, key, _value, seq) => {
+ if (changedSession === session && key === 'test/marks') notifications.push(seq)
+ })
+ } else if (!listening && stop !== undefined) {
+ stop()
+ stop = undefined
+ }
+ }
+
+ setListening(baselineKnown)
+ mark(session, [`${scenario}:0`])
+ computedViews.length = 0
+ notifications.length = 0
+
+ const expectedViews: MarksView[] = []
+ const expectedNotifications: number[] = []
+ let comparable = baselineKnown
+ ? views[viewSequence[stateSequence[0] as number] as number] as MarksView
+ : undefined
+ for (let index = 1; index < stateSequence.length; index++) {
+ const listening = (listenerMask & (1 << (index - 1))) !== 0
+ setListening(listening)
+ const changed = stateSequence[index] !== stateSequence[index - 1]
+ if (changed) {
+ if (listening) {
+ const current = views[viewSequence[stateSequence[index] as number] as number] as MarksView
+ expectedViews.push(current)
+ if (comparable === undefined || !Object.is(comparable, current)) {
+ expectedNotifications.push(index)
+ }
+ comparable = current
+ } else {
+ comparable = undefined
+ }
+ }
+ mark(session, [`${scenario}:${String(index)}`])
+ }
+ setListening(false)
+
+ if (!sameIdentities(computedViews, expectedViews)
+ || notifications.length !== expectedNotifications.length
+ || notifications.some((seq, index) => seq !== expectedNotifications[index])) {
+ mismatchCount += 1
+ const stateName = sequenceName(stateSequence, 'v')
+ if (!failures.has(stateName) || (baselineKnown && listenerMask === 7)) {
+ failures.set(stateName, {
+ state: stateName,
+ view: stateSequence.map(value => `r${String((viewSequence[value] as number) + 1)}`).join(','),
+ baseline: baselineKnown ? 'known' : 'unknown',
+ listeners: [0, 1, 2]
+ .map(index => (listenerMask & (1 << index)) === 0 ? 'off' : 'on')
+ .join(','),
+ expectedViewCalls: expectedViews.length,
+ actualViewCalls: computedViews.length,
+ expectedNotifications,
+ actualNotifications: [...notifications],
+ })
+ }
+ }
+ computedViews.length = 0
+ }
+ }
+ }
+ }
+
+ expect({ checked, mismatchCount, failures: [...failures.values()] }).toEqual({
+ checked: 960,
+ mismatchCount: 0,
+ failures: [],
+ })
+ })
+
it('drives independently per session (cells are per-session watermarks)', async () => {
const { ctx, session } = await harness()
const other = ctx.sessions.create()