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()
})