diff --git a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.i18n.yaml
index 20a912b5f3..4938850728 100644
--- a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.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-09-14-step-process-disclosures.md
-2026-09-14-step-process-disclosures.md: 127556fc68d6c345041ab4c475994c1636a80887
-2026-09-14-step-process-disclosures.zh.md: d1e11d6c792ce2e13b8b100e3c37a4ce2c05e59e
+2026-09-14-step-process-disclosures.md: 2c6b49b96612b7cf6be7d6bff5409b80d1776175
+2026-09-14-step-process-disclosures.zh.md: 8e008e1a6b6eee251cbfbd07e3dca4ee94fcaba7
diff --git a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md
index 127556fc68..2c6b49b966 100644
--- a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md
+++ b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md
@@ -12,7 +12,7 @@ Whole-turn folding only shortens completed turns. Long ongoing turns interleave
The [Chat renderer](../../../../packages/client/ui-chat/README.md#turn-process-folding) groups consecutive process material between assistant responses. Reasoning and response blocks within one assistant node have separate renderer seats so streaming text closes the preceding process range without hiding the response. Group disclosure state survives updates under its first member key; the existing whole-turn disclosure remains the outer visibility owner. Its title reports elapsed time between the recorded turn boundaries; absent timestamps omit the duration.
-Compact and completed titles summarize tool categories, not individual tool names or infrastructure records. Every distinct call contributes once, including repeated paths. Count ranks the top three categories but stays out of the title; ties retain first appearance order. Two categories use “and”, three use commas, and only more than three add “etc.” Chinese uses “并”, “,” and “等”, sharing “已” only when both of two phrases start with it. English continuation phrases start lowercase. Running groups describe the newest running tool category, otherwise analysis activity, and animate that text with a repeating ease-in-out shimmer whose one-second pass is followed by a 500ms pause until the range closes. Detailed adds a concise current-call detail to the category's right, preferring authored task text and common command, query, URL, or path fields before falling back to the exact tool name. While no tool is running, the detail comes from the latest live reasoning paragraph. Details are whitespace-normalized and capped at 160 characters. Each displayed live title remains for at least 150ms; updates during that interval coalesce to the latest title instead of queuing intermediate tools; a completed group without tools uses the analysis fallback. Retry rows remain independent of secondary process groups. Each secondary title starts with the matching existing Tool icon; commands use Bash's `IconApiOutline14`. Hover or keyboard focus replaces that leading icon in place with a down chevron while collapsed; an expanded title keeps an up chevron visible, and no trailing chevron remains. Hovering whole-Turn and secondary disclosure titles darkens their text. A collapsed secondary title has no vertical padding and sits 16px from adjacent Assistant messages. An expanded title has 16px of space before its first process row, while adjacent process rows use 8px. Expanded content has a viewport-relative height cap and its own scroll area. Each scrollable edge carries a 24px fade that disappears when the reader reaches that edge.
+Compact and completed titles summarize tool categories, not individual tool names or infrastructure records. Every distinct call contributes once, including repeated paths. Count ranks the top three categories but stays out of the title; ties retain first appearance order. Two categories use “and”, three use commas, and only more than three add “etc.” Chinese uses “并”, “,” and “等”, sharing “已” only when both of two phrases start with it. English continuation phrases start lowercase. Running groups describe the newest running tool category, otherwise analysis activity, and animate that text with a repeating ease-in-out shimmer whose one-second pass is followed by a 500ms pause until the range closes. Detailed adds a concise current-call detail to the category's right, preferring authored task text and common command, query, URL, or path fields before falling back to the exact tool name. While no tool is running, the detail comes from the latest live reasoning paragraph. Details are whitespace-normalized and capped at 160 characters. Each displayed live title remains for at least 150ms; updates during that interval coalesce to the latest title instead of queuing intermediate tools; a completed group without tools uses the analysis fallback. Retry rows remain independent of secondary process groups. Each secondary title starts with the matching existing Tool icon; commands use Bash's `IconApiOutlineRegular`. Hover or keyboard focus replaces that leading icon in place with a down chevron while collapsed; an expanded title keeps an up chevron visible, and no trailing chevron remains. Hovering whole-Turn and secondary disclosure titles darkens their text. A collapsed secondary title has no vertical padding and sits 16px from adjacent Assistant messages. An expanded title has 16px of space before its first process row, while adjacent process rows use 8px. Expanded content has a viewport-relative height cap and its own scroll area. Each scrollable edge carries a 24px fade that disappears when the reader reaches that edge.
Whole-turn folding checks each Turn independently: while history is paginated, its start must be loaded. Requiring all Session history would disable folding throughout long goal and job sessions. Loading a missing start enables that Turn without waiting for unrelated older Turns.
@@ -28,6 +28,8 @@ The whole-turn control appears at the start event and remains expanded and disab
Work details provides Compact, Detailed, and Expanded defaults for the two disclosure levels. Legacy compact settings remain Compact and normal settings resolve to Detailed on read; Expanded is a new option; new choices persist only current values.
+The Chat snapshot builder owns group layout, summaries, and footer eligibility. Content-only updates use a node-to-group index and preserve historical group identities. Renderer-seat boundary changes and pagination rebuild the layout. This keeps full-history grouping out of streaming renderers and makes footer selectors constant-time. A missing Turn start leaves its trailing group live until a response or recorded completion closes it. Lifecycle announcements use a separate polite status region so elapsed-time ticks remain silent.
+
## Consequences
The grouping changes presentation only and adds no Session events or model input. Tool names determine categories; unmatched custom tools use the generic category. Disclosure state is local to the mounted chat view. Closing the whole Turn resets nested process groups and remounts hidden process renderers so tool and detail disclosures start collapsed when reopened. Pagination that changes a group's first member may reset its expansion. Existing per-tool disclosures remain available inside the capped process area.
diff --git a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.zh.md b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.zh.md
index d1e11d6c79..8e008e1a6b 100644
--- a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.zh.md
@@ -12,7 +12,7 @@ Status: implemented
[Chat 渲染器](../../../../packages/client/ui-chat/README.zh.md#turn-process-folding) 将 Assistant 回复之间的连续过程内容分组。同一 Assistant 节点中的推理与回复块使用独立的渲染位置,因此流式正文会闭合前面的过程组,同时保持回复外露。分组以首个成员的键保持更新前后的展开状态,现有整轮折叠仍负责外层可见性。其标题展示记录的轮次起止时间之间的用时,时间戳缺失时省略时长。
-简洁模式与完成后的标题只概括工具类别,不展示各工具名或基础设施记录。每个不同调用计数一次,同一路径的多次调用分别计数。次数仅用于选取前三类,不显示在标题中;并列时按首次出现顺序排列。两类用「并」,三类用「,」,仅超过三类才加「等」;两项均以「已」开头时共享首个「已」。英文对应 “and”、逗号和 “etc.”,后续短语首字母小写。进行中的组显示最新运行工具的类别,否则显示分析阶段,并在该组关闭前循环显示采用 ease-in-out 曲线、每次移动一秒并随后停顿 500ms 的文字扫光。详细模式在类别右侧加入简短的当前调用详情,优先采用调用方给出的任务文字及常见命令、查询词、URL 或路径字段,没有可用详情时回退到原始工具名;没有工具运行时,详情来自最新的实时思考段落。详情会合并空白并截断到 160 个字符。每个已显示的进行中标题至少保留 150ms;期间的更新只保留最新标题,不排队补播中间工具;完成组没有工具时使用分析完成的回退文案。重试行独立展示,不归入二级过程组。每个二级标题默认以前置的现有工具图标表示类别,命令类别使用 Bash 的 `IconApiOutline14`。收起时,悬停或键盘聚焦会把前置图标原位替换为向下箭头;展开后持续显示向上箭头,标题尾部不显示箭头。悬停整轮或二级折叠标题时,文字颜色加深。收起的二级标题不带垂直内边距,与相邻 Assistant 消息相隔 16px。展开标题与首个过程行相隔 16px,相邻过程行相隔 8px。展开内容按视口限制高度,并独立滚动;每个仍可滚动的方向显示 24px 渐隐遮罩,到达对应边缘时消失。
+简洁模式与完成后的标题只概括工具类别,不展示各工具名或基础设施记录。每个不同调用计数一次,同一路径的多次调用分别计数。次数仅用于选取前三类,不显示在标题中;并列时按首次出现顺序排列。两类用「并」,三类用「,」,仅超过三类才加「等」;两项均以「已」开头时共享首个「已」。英文对应 “and”、逗号和 “etc.”,后续短语首字母小写。进行中的组显示最新运行工具的类别,否则显示分析阶段,并在该组关闭前循环显示采用 ease-in-out 曲线、每次移动一秒并随后停顿 500ms 的文字扫光。详细模式在类别右侧加入简短的当前调用详情,优先采用调用方给出的任务文字及常见命令、查询词、URL 或路径字段,没有可用详情时回退到原始工具名;没有工具运行时,详情来自最新的实时思考段落。详情会合并空白并截断到 160 个字符。每个已显示的进行中标题至少保留 150ms;期间的更新只保留最新标题,不排队补播中间工具;完成组没有工具时使用分析完成的回退文案。重试行独立展示,不归入二级过程组。每个二级标题默认以前置的现有工具图标表示类别,命令类别使用 Bash 的 `IconApiOutlineRegular`。收起时,悬停或键盘聚焦会把前置图标原位替换为向下箭头;展开后持续显示向上箭头,标题尾部不显示箭头。悬停整轮或二级折叠标题时,文字颜色加深。收起的二级标题不带垂直内边距,与相邻 Assistant 消息相隔 16px。展开标题与首个过程行相隔 16px,相邻过程行相隔 8px。展开内容按视口限制高度,并独立滚动;每个仍可滚动的方向显示 24px 渐隐遮罩,到达对应边缘时消失。
整轮折叠按轮次独立判断:历史分页未加载完时,该轮起点必须已加载。要求整个 Session 历史加载完整会让长时间运行的 goal 和 job 会话无法折叠。补齐起点即可启用该轮折叠,无需等待无关的更早轮次。
@@ -28,6 +28,8 @@ Status: implemented
工作过程展示通过简洁、详细、完全展开三档控制两级分组的默认状态。旧 compact 设置保持简洁,normal 设置读取为详细;完全展开是新增选项;新选择仅保存当前三档值。
+Chat 快照构建器负责分组布局、摘要和页脚展示条件。仅内容变化的更新通过节点到分组的索引定位,并保留历史分组引用。渲染位置边界变化和分页会重建布局。因此流式渲染器不再执行全历史分组,页脚选择器以常数时间读取结果。缺失轮次起点时,末尾分组保持进行中,直到回复或记录的完成事件将其闭合。生命周期通过单独的礼貌播报区域通知读屏器,耗时时钟跳动保持静默。
+
## Consequences
分组仅改变展示,不增加 Session 事件或模型输入。分类由工具名决定,未匹配的自定义工具计入通用类别。展开状态保存在当前挂载的聊天视图中。收起整轮会重置内部过程组,并重新挂载已隐藏的过程 renderer,让工具和详情在重新打开时保持折叠。分页改变分组首个成员时可能重置展开状态。各工具原有的独立折叠仍可在限高过程区内使用。
diff --git a/apps/web/tests/bash-abort-row.e2e.ts b/apps/web/tests/bash-abort-row.e2e.ts
index ce42436859..50ec72cb3b 100644
--- a/apps/web/tests/bash-abort-row.e2e.ts
+++ b/apps/web/tests/bash-abort-row.e2e.ts
@@ -43,6 +43,10 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
+ await page.locator('[data-step-process] > button').first().waitFor()
+ for (const toggle of await page.locator('[data-step-process] > button[aria-expanded="false"]').all()) {
+ await toggle.click()
+ }
await page.locator('[data-sample="bash"]').nth(1).waitFor({ timeout: 15_000 })
}, 120_000)
diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts
index 21e4f78bae..fdd58aec6d 100644
--- a/apps/web/tests/clickable-links-gallery.e2e.ts
+++ b/apps/web/tests/clickable-links-gallery.e2e.ts
@@ -377,7 +377,10 @@ describe('web e2e: clickable links gallery', () => {
// it opens first. Rows expand via a right-edge click: the row center can
// land on the nested fileLink button, which would hand the path to the
// Host's opener.
- await page.getByRole('button', { name: `${String(CALLS.length)} tool calls` }).click()
+ await page.locator('[data-turn-process]').click()
+ for (const toggle of await page.locator('[data-step-process] > button[aria-expanded="false"]').all()) {
+ await toggle.click()
+ }
for (const row of [
/^Search clickable link styles/,
/^Fetch /,
diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md
index 67f77b8b26..b0bcd6abad 100644
--- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md
+++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md
@@ -8,7 +8,9 @@
- tab "Trajectory"
- text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}"
- button "Copy"
-- button "16 tool calls" [expanded]
+- status: Worked
+- button "Took {{duration}}" [expanded]
+- button "Edited files, called tools, searched code, etc." [expanded]
- button "Write site/report.html +1 -0":
- text: Write
- button "site/report.html"
diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md
index 1bf5dcfa51..d6424db6ca 100644
--- a/apps/web/tests/expected/steer-all/mid-steer.expected.md
+++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
+- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- button "Waiting for your action"
- text: "Interjection: include the word BANANA in your final reply."
diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md
index 7a3ae76193..f6390723d4 100644
--- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md
+++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
+- status: Worked
- button "Took {{duration}}" [expanded]
- button "Asked questions"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md
index 433d71d168..a230122bf6 100644
--- a/apps/web/tests/expected/steer-all/settled.expected.md
+++ b/apps/web/tests/expected/steer-all/settled.expected.md
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
+- status: Worked
- button "Took {{duration}}"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy"
diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts
index 9b6ebf857f..1361a476d3 100644
--- a/apps/web/tests/goal-multi-turn-actions.e2e.ts
+++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts
@@ -155,9 +155,9 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
expect(sessionEvents.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
await expect.poll(() => page.locator('[data-turn-process]').count(), { timeout: 15_000 }).toBe(2)
- expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(2)
+ expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0)
expect(await page.locator(
- '[data-chat-flow-kind="system-prompt"][hidden="until-found"]',
+ '[data-chat-flow-kind="system-prompt"]',
).count()).toBe(0)
const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index d680cadf13..cc724f33c8 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -161,7 +161,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
const liveLabel = await liveControl.textContent()
expect(liveLabel).toMatch(/^Deep diving for /)
await expect.poll(() => liveControl.textContent(), { timeout: 10_000 }).not.toBe(liveLabel)
- expect(await page.getByRole('status').filter({ hasText: 'Deep diving...' }).count()).toBe(0)
+ expect(await page.getByRole('status').filter({ hasText: 'Deep diving...' }).count()).toBe(1)
await page.locator('[data-streaming="true"]')
.getByText('partial', { exact: true })
.waitFor({ timeout: 30_000 })
diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
index 0dfac96faf..938fbad536 100644
--- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
+++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
- button "Copy"
+- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- text: ts
- button "Copy"
diff --git a/apps/web/tests/step-process.e2e.ts b/apps/web/tests/step-process.e2e.ts
index 160893c256..875c9c0f0d 100644
--- a/apps/web/tests/step-process.e2e.ts
+++ b/apps/web/tests/step-process.e2e.ts
@@ -76,8 +76,7 @@ it('keeps completed step work collapsed inside an expanded turn and bounds its s
expect(await body.getAttribute('hidden')).toBeNull()
await expect.poll(() => activityIcon.evaluate(element => getComputedStyle(element).opacity)).toBe('0')
await expect.poll(() => chevron.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
- expect(await chevron.locator('path').getAttribute('d'))
- .toBe('M12 10L8.70711 6.70711C8.31658 6.31658 7.68342 6.31658 7.29289 6.70711L4 10')
+ expect(await toggle.getAttribute('aria-expanded')).toBe('true')
expect(await toggle.evaluate(element => getComputedStyle(element).paddingBottom)).toBe('16px')
const leafSpacing = await body.evaluate((element) => {
const rows = [...element.children].filter(row => !row.hasAttribute('hidden'))
@@ -95,7 +94,6 @@ it('keeps completed step work collapsed inside an expanded turn and bounds its s
})
await expect.poll(() => body.getAttribute('data-scroll-up')).toBeNull()
await expect.poll(() => body.getAttribute('data-scroll-down')).toBe('true')
- await page.screenshot({ path: '/tmp/dsh-step-process-expanded.png' })
const geometry = await body.evaluate((element) => {
const style = getComputedStyle(element)
element.scrollTop = (element.scrollHeight - element.clientHeight) / 2
@@ -114,7 +112,6 @@ it('keeps completed step work collapsed inside an expanded turn and bounds its s
element.dispatchEvent(new Event('scroll'))
})
await expect.poll(() => body.getAttribute('data-scroll-down')).toBeNull()
- await page.screenshot({ path: '/tmp/dsh-step-process-expanded.png' })
await outer.click()
await outer.click()
expect(await toggle.getAttribute('aria-expanded')).toBe('false')
@@ -273,7 +270,6 @@ it('keeps a waking notice above and independent of the turn disclosure', async (
await outer.ariaSnapshot(), webSnapshotMode())
expect(await button.getAttribute('aria-expanded')).toBe('true')
expect(await notice.isVisible()).toBe(true)
- await page.screenshot({ path: '/tmp/dsh-turn-trigger-expanded.png' })
} finally { await browser.close() }
} finally { await scaffold.close() }
})
diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts
index 92179c6237..577602717d 100644
--- a/apps/web/tests/turn-tail-actions.e2e.ts
+++ b/apps/web/tests/turn-tail-actions.e2e.ts
@@ -138,7 +138,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
const liveProcess = page.locator('[data-turn-process]')
expect(await liveProcess.count()).toBe(1)
expect(await liveProcess.textContent()).toMatch(/^Deep diving for /)
- expect(await page.getByRole('status').filter({ hasText: 'Deep diving...' }).count()).toBe(0)
+ expect(await page.getByRole('status').filter({ hasText: 'Deep diving...' }).count()).toBe(1)
await page.locator('[data-streaming="true"]')
.getByText('partial', { exact: true })
.waitFor({ timeout: 10_000 })
diff --git a/benchmarks/conversation-fold/conversation-fold.worker.client.ts b/benchmarks/conversation-fold/conversation-fold.worker.client.ts
index 707051a4dc..8820d32fdb 100644
--- a/benchmarks/conversation-fold/conversation-fold.worker.client.ts
+++ b/benchmarks/conversation-fold/conversation-fold.worker.client.ts
@@ -18,7 +18,7 @@ import { chatViewDefinition } from '../../packages/client/ui-chat/src/client/con
import { commandDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/command.ts'
import { compactionDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/compaction.ts'
import { unknownFallbackDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/fallback.ts'
-import { nextStepInboxDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts'
+import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts'
import { messageDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/message.ts'
import { requestPromptDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts'
import { retryDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/retry.ts'
@@ -45,6 +45,7 @@ export interface ConversationFoldWorkerReport {
class BenchEventDefinitions {
readonly definitions: readonly ConversationNodeDefinition[] = [
nextStepInboxDefinition,
+ nextTurnInboxDefinition,
messageDefinition,
requestPromptDefinition(inspectRequestPrompt),
assistantDefinition,
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 3228790123..e539ded5fe 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: b434e547f1930f9be2d4323b90a023cca7cb5188
-README.zh.md: 2f2546e23c2c1b45af84e40b04a19fa1d2c85657
+README.md: 7f28c9febc5e4296bd6938a0be143dac47eae5c6
+README.zh.md: 0fbd5c2c84953bc8105259ab74adabdf96d4fd6e
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index b434e547f1..7f28c9febc 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -15,7 +15,8 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
## Table of Contents
- [Reference previews](#reference-previews)
-- [System prompt row](#system-prompt-row)
+- [System prompt visibility](#system-prompt-row)
+- [Command and failure rows](#command-and-failure-rows)
- [Turn token usage](#turn-token-usage)
- [Completed-turn footer](#completed-turn-footer)
- [Turn Process Folding](#turn-process-folding)
@@ -29,12 +30,21 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
## Reference previews
-Sent file references and skills confirmed by the message’s logged invocation open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references.
+Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state.
+
+HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references.
-## System prompt row
+## System prompt visibility
-Each nonempty appended `system/message` owns a collapsed prompt row, including a complete prompt at the start of a headerless window; the same-step header does not duplicate it. Chat also shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives.
+System prompts have no Chat row in any Work details mode. Their durable records remain available in trajectory inspection; Chat filtering does not change model input.
+
+
+## Command and failure rows
+
+Generic command rows retain the ordinary command glyph in every lifecycle state; failure remains explicit through the row state and summary. A terminal Turn failure remains a separate red-dot notice; intermediate model retries do not create that notice, and an output-token limit uses the amber warning dot.
+
+-----
## Turn token usage
@@ -52,6 +62,8 @@ The completed-turn action footer sits at the bottom of its Turn, 20px below the
-----
+The Chat snapshot builder owns stable step-group layout, cached activity summaries, and Turn-footer eligibility. Body-only stream updates refresh the affected groups without scanning historical groups; renderer-seat changes and pagination rebuild layout while retaining unchanged group identities. Unknown Turn status does not mark a trailing group complete. Whole-Turn lifecycle changes announce through a polite status region; clock ticks do not repeat that announcement.
+
## Turn Process Folding
@@ -108,4 +120,4 @@ None.
**Runtime invariant:** No companion is published. Conversation and Slot registration enforce Chat target consistency.
-Non-human input claimed from the next-turn Inbox appears as an independent trigger notice above the Turn control. A first-step next-step claim also qualifies when that Turn has no next-turn claim or human input in its next-step batch. Ordinary pre-step injections remain context rows. The compact outlined header shows a source-family icon, title, and subdued time without repeating a subject, and the whole notice highlights on hover. The notice stays visible in every work-details mode, defaults to collapsed, and expands independently of process folding to show a short explanation and the original model-facing content without source metadata. Titles recognize schedules, jobs, goals, agent/team messages, webhooks, and Cordis runner outcomes; task messages use the paper-plane icon, each other recognized source family uses its matching primitive icon, and unknown sources or status formats retain the context-injection fallback. User-attributed SDK and delegated inputs remain user messages because the log does not distinguish their caller.
+Non-human input claimed from the next-turn Inbox appears as an independent trigger notice above the Turn control. A first-step next-step claim also qualifies when that Turn has no next-turn claim or human input in its next-step batch. Ordinary pre-step injections are hidden in every Work details mode; their durable records remain available in trajectory inspection. The compact outlined header shows a source-family icon, title, and subdued time without repeating a subject, and the whole notice highlights on hover. The notice stays visible in every work-details mode, defaults to collapsed, and expands independently of process folding to show a short explanation and the original model-facing content without source metadata. Titles recognize schedules, jobs, goals, agent/team messages, webhooks, and Cordis runner outcomes; task messages use the paper-plane icon, each other recognized source family uses its matching primitive icon, and unknown sources or status formats retain the context-injection fallback. User-attributed SDK and delegated inputs remain user messages because the log does not distinguish their caller.
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index 2f2546e23c..0fbd5c2c84 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -15,7 +15,8 @@ kind: "package-reference"
## 目录
- [引用预览](#reference-previews)
-- [系统提示词行](#system-prompt-row)
+- [系统提示词可见性](#system-prompt-row)
+- [指令与失败行](#command-and-failure-rows)
- [轮次 token 用量](#turn-token-usage)
- [已完成轮次的页脚](#completed-turn-footer)
- [轮次过程折叠](#turn-process-folding)
@@ -29,12 +30,19 @@ kind: "package-reference"
## 引用预览
-已发送的文件引用及消息日志确认调用的 skill 可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
+Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。
+
+Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
-## 系统提示词行
+## 系统提示词可见性
-每个非空追加的 `system/message` 都拥有一行折叠提示词,包括无 header 窗口起点的完整提示词;同一步骤的 header 不会重复它。Chat 也会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的 `系统提示词`。即使系统文本未变,恢复也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示模型所见的确切文本,并保留其原始换行。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。
+所有工作过程展示模式均不显示系统提示词行。其持久化记录仍可在轨迹中查看;Chat 过滤不改变模型输入。
+
+
+## 指令与失败行
+
+通用指令行在所有生命周期状态中都保留普通指令图标;失败仍通过行状态与摘要明确表达。终止轮次的错误仍是独立的红点提示;模型的中间重试不会创建该提示,达到输出 token 上限时使用琥珀色警告点。
-----
@@ -54,6 +62,8 @@ kind: "package-reference"
-----
+Chat 快照构建器维护稳定的步骤分组布局、缓存的活动摘要和轮次页脚展示条件。仅正文变化的流式更新只刷新受影响的分组,不扫描历史分组;渲染位置变化和分页会重建布局,同时保留未变化分组的引用。未知的轮次状态不会把末尾分组标记为完成。整轮生命周期变化通过礼貌播报区域通知读屏器,时钟跳动不会重复播报。
+
## 轮次过程折叠
@@ -110,4 +120,4 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有
**运行时不变式:** 不发布伴生入口。Conversation 与 slot 注册已经强制 Chat target 一致。
-从 next-turn Inbox 领取的非人工输入在轮次控件上方显示独立触发提示。第一步领取的 next-step 输入在本轮没有 next-turn 领取、且同批没有人工输入时也显示提示。普通 pre-step 注入仍保留为上下文行。紧凑的浅描边标题行只显示来源类别图标、标题与弱化时间,不重复摘要;悬停时整条提示高亮。提示在所有工作过程展示模式下均可见,默认收起,并独立于过程折叠显示简短解释及模型实际收到的原始内容,不显示来源元数据。标题识别定时任务、后台任务、目标、任务或团队消息、Webhook 与 Cordis runner 结果;任务消息使用纸飞机图标,其他已识别来源使用对应的 primitive 图标,未知来源或状态格式使用上下文注入图标作为回退。归因为 user 的 SDK 或委派输入仍作为用户消息展示,因为日志无法区分其调用方。
+从 next-turn Inbox 领取的非人工输入在轮次控件上方显示独立触发提示。第一步领取的 next-step 输入在本轮没有 next-turn 领取、且同批没有人工输入时也显示提示。所有工作过程展示模式均隐藏普通 pre-step 注入,其持久化记录仍可在轨迹中查看。紧凑的浅描边标题行只显示来源类别图标、标题与弱化时间,不重复摘要;悬停时整条提示高亮。提示在所有工作过程展示模式下均可见,默认收起,并独立于过程折叠显示简短解释及模型实际收到的原始内容,不显示来源元数据。标题识别定时任务、后台任务、目标、任务或团队消息、Webhook 与 Cordis runner 结果;任务消息使用纸飞机图标,其他已识别来源使用对应的 primitive 图标,未知来源或状态格式使用上下文注入图标作为回退。归因为 user 的 SDK 或委派输入仍作为用户消息展示,因为日志无法区分其调用方。
diff --git a/packages/client/ui-chat/src/client/chat/ContextBody.module.css b/packages/client/ui-chat/src/client/chat/ContextBody.module.css
index b9919c440b..14a3ec3b88 100644
--- a/packages/client/ui-chat/src/client/chat/ContextBody.module.css
+++ b/packages/client/ui-chat/src/client/chat/ContextBody.module.css
@@ -1,5 +1,4 @@
-/* Expanded context bodies: one code-block surface shared by every form, so the
- disclosure keeps the Figma 10:2482 geometry whichever form renders inside. */
+/* Model-facing content within a Turn trigger notice. */
.text {
margin: 0;
@@ -8,154 +7,3 @@
white-space: pre-wrap;
overflow-wrap: anywhere;
}
-
-/* Source fields beneath the text: dimmer than the content they describe. */
-.fields {
- display: flex;
- flex-direction: column;
- gap: 2px;
- margin: 8px 0 0;
- padding-top: 8px;
- border-top: 0.5px solid var(--dsw-alias-border-l2);
-}
-
-.field {
- display: flex;
- gap: 8px;
- min-width: 0;
-}
-
-.fieldKey {
- flex: none;
- min-width: 96px;
- color: var(--dsw-alias-label-caption);
-}
-
-.fieldValue {
- flex: 1 1 auto;
- min-width: 0;
- margin: 0;
- color: var(--dsw-alias-label-tertiary);
- overflow-wrap: anywhere;
-}
-
-/* instructions: the reconciled files, above their text. */
-.files {
- display: flex;
- flex-wrap: wrap;
- gap: 4px 12px;
- margin: 0 0 8px;
- padding: 0;
- list-style: none;
-}
-
-.file {
- display: flex;
- align-items: baseline;
- gap: 6px;
- min-width: 0;
-}
-
-.filePath {
- color: var(--dsw-alias-label-secondary);
- overflow-wrap: anywhere;
-}
-
-.fileAction {
- color: var(--dsw-alias-label-caption);
-}
-
-/* catalog: a replacement notice above one row per published entry. */
-.catalogNotice {
- margin: 0 0 6px;
- color: var(--dsw-alias-label-caption);
-}
-
-
-.entries {
- display: flex;
- flex-direction: column;
- gap: 4px;
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.entry {
- display: flex;
- gap: 8px;
- min-width: 0;
-}
-
-.entryName {
- flex: none;
- color: var(--dsw-alias-label-secondary);
-}
-
-.entryDescription {
- flex: 1 1 auto;
- min-width: 0;
- overflow: hidden;
- color: var(--dsw-alias-label-tertiary);
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* snapshot: one titled block per contributing subsystem. */
-.sections {
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin: 0;
-}
-
-.section {
- display: flex;
- flex-direction: column;
- gap: 2px;
- min-width: 0;
-}
-
-.sectionName {
- color: var(--dsw-alias-label-caption);
-}
-
-.sectionText {
- margin: 0;
- color: var(--dsw-alias-label-secondary);
- white-space: pre-wrap;
- overflow-wrap: anywhere;
-}
-
-/* relay: who sent this, above what they said. */
-.relaySender {
- margin: 0 0 6px;
- color: var(--dsw-alias-label-caption);
- overflow-wrap: anywhere;
-}
-
-/* recall: one row per source session, with how much of it survived. */
-.recalls {
- display: flex;
- flex-direction: column;
- gap: 2px;
- margin: 0 0 8px;
- padding: 0;
- list-style: none;
-}
-
-.recall {
- display: flex;
- gap: 8px;
- min-width: 0;
-}
-
-.recallLabel {
- color: var(--dsw-alias-label-secondary);
- overflow-wrap: anywhere;
-}
-
-.recallCounts {
- flex: none;
- color: var(--dsw-alias-label-caption);
-}
diff --git a/packages/client/ui-chat/src/client/chat/ContextBody.tsx b/packages/client/ui-chat/src/client/chat/ContextBody.tsx
index 270da5052c..ec13a60a8d 100644
--- a/packages/client/ui-chat/src/client/chat/ContextBody.tsx
+++ b/packages/client/ui-chat/src/client/chat/ContextBody.tsx
@@ -1,44 +1,19 @@
-// Expanded bodies for the context disclosure, one per durable context form.
-// The producer declares the form; this module only chooses a presentation for
-// it. Every form falls back to OpaqueBody, which is the documented default for
-// an absent, unknown, or malformed form — a resumed or foreign log must render
-// even when this UI version has never seen its producer.
-
+/** Original model-facing content retained by non-human Turn notices. */
import type { ReactNode } from 'react'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { ContextMessageNode } from '../contract/snapshot.ts'
-import type { KnownContextForm } from '@deepseek-ai/dsh-client-ui-conversation/client'
import css from './ContextBody.module.css'
/** Model-facing text stays bounded at the disclosure, not at the producer. */
const MAX_CHARS = 20_000
-/** Rows a list body materializes before summarizing the remainder. */
-const MAX_ENTRIES = 200
-
type Translate = ChatViewSlotProps['t']
-/** One durable source narrowed to the readable-record shape; null for anything else. */
-function asRecord(value: unknown): Record | null {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
- ? value as Record
- : null
-}
-
/** One run of the model-facing content: adjacent text, or one unknown block. */
type ContentRun = { text: string } | { block: unknown }
-/**
- * The content blocks as runs, IN THE ORDER the model received them.
- *
- * Adjacent text blocks join with no separator, matching how provider adapters
- * flatten them — inserting a line break would show the reader a line the model
- * never saw. An unknown block breaks the run and keeps its own fallback rather
- * than being hoisted past the text around it or vanishing; the block union is
- * merge-extensible, so a foreign log may interleave shapes this build does not
- * know.
- */
+/** Join adjacent text while preserving unknown blocks in their original order. */
function contentRuns(content: ContextMessageNode['content']): ContentRun[] {
const runs: ContentRun[] = []
for (const block of content) {
@@ -53,11 +28,6 @@ function contentRuns(content: ContextMessageNode['content']): ContentRun[] {
return runs
}
-/** Only the blocks this UI version does not know, for bodies that replace the text. */
-function unknownBlocks(content: ContextMessageNode['content']): unknown[] {
- return contentRuns(content).flatMap(run => 'block' in run ? [run.block] : [])
-}
-
/** The model-facing text, truncated to the display bound. */
function boundedText(text: string, t: Translate): string {
return text.length > MAX_CHARS
@@ -66,75 +36,9 @@ function boundedText(text: string, t: Translate): string {
}
/**
- * One source field rendered as a value row; nested shapes stay compact JSON.
- * Bounded on its own, because source fields are as unbounded as the text: an unknown
- * producer may record an arbitrarily large string or array.
- */
-function fieldValue(value: unknown, t: Translate): string {
- const text = typeof value === 'string'
- ? value
- : typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value)
- return boundedText(text, t)
-}
-
-/**
- * Source fields as a key/value list. `kind` is always omitted because the
- * row header already names the producer. `form` is omitted only when a
- * dedicated body rendered for it — then the presentation the reader is looking
- * at IS that value. On the opaque fallback the declaration is kept, because
- * that is the one place a form this version cannot present would otherwise
- * disappear from the UI entirely.
- */
-function SourceFields({ source, formRendered, t }: {
- source: unknown
- formRendered: boolean
- t: Translate
-}): ReactNode {
- const record = asRecord(source)
- if (record === null) return null
- const hidden = formRendered ? ['kind', 'form'] : ['kind']
- const rows = Object.entries(record).filter(([key]) => !hidden.includes(key))
- if (rows.length === 0) return null
- return (
-
- {rows.map(([key, value]) => (
-
-
{key}
-
{fieldValue(value, t)}
-
- ))}
-
- )
-}
-
-/**
- * Content blocks this UI version does not know, kept visible rather than
- * dropped: the block union is merge-extensible, so a newer or foreign log may
- * carry a shape this build has no presentation for.
- * @param props - The unrecognized blocks and the locale seat.
- * @returns One generic JSON block per unknown entry.
- */
-function UnknownBlocks({ blocks, t }: { blocks: readonly unknown[]; t: Translate }): ReactNode {
- return (
- <>
- {blocks.map((block, index) => (
- t('json.truncated', { total })}
- />
- ))}
- >
- )
-}
-
-/**
- * The model-facing content of one context, shared by every form that shows it:
- * the text with its real line breaks, then any block this UI version does not
- * know, which keeps its own fallback rather than vanishing.
- * @param props - Durable content and the locale seat.
- * @returns The content blocks as the model received them.
+ * Render the recorded content of a Turn trigger, including unknown blocks.
+ * @param props - durable content and the locale seat.
+ * @returns bounded text and JSON fallbacks in their original order.
*/
export function ContextContentBody({ content, t }: {
content: ContextMessageNode['content']
@@ -157,436 +61,3 @@ export function ContextContentBody({ content, t }: {
>
)
}
-
-/**
- * Default presentation: the model-facing text as text, with its real line
- * breaks, and the remaining source fields beneath it. This is what every form
- * this UI version does not recognize renders as.
- * @param props - Durable content, its source, and the locale seat.
- * @returns The opaque context body.
- */
-export function OpaqueBody({ content, source, t }: {
- content: ContextMessageNode['content']
- source: unknown
- t: Translate
-}): ReactNode {
- return (
- <>
-
-
- >
- )
-}
-
-/** One reconciled instruction file, as the durable source records it. */
-interface InstructionChange {
- action: 'set' | 'replace' | 'remove'
- path: string
- digest?: string
-}
-
-/**
- * Instruction changes read off the source, or null when the record is not a
- * usable instruction list.
- *
- * The read is all-or-nothing: silently dropping one unreadable entry would show
- * a confident, incomplete file list for a log this version cannot fully read.
- * Paths are deduplicated in first-seen order, matching how the header label is
- * derived from the same array.
- */
-function instructionChanges(source: unknown): InstructionChange[] | null {
- const record = asRecord(source)
- const list = record === null ? undefined : record['changes']
- if (!Array.isArray(list)) return null
- const changes: InstructionChange[] = []
- const seen = new Set()
- for (const entry of list as readonly unknown[]) {
- const change = asRecord(entry)
- if (change === null) return null
- const path = change['path']
- if (typeof path !== 'string' || path === '') return null
- const action = change['action']
- // The action decides which word the row shows, so an unrecognized one is
- // not a readable change — it would be presented as loaded or updated.
- if (action !== 'set' && action !== 'replace' && action !== 'remove') return null
- const digest = change['digest']
- if (seen.has(path)) continue
- seen.add(path)
- changes.push({ action, path, ...typeof digest === 'string' ? { digest } : {} })
- }
- return changes.length === 0 ? null : changes
-}
-
-/**
- * Locale key for one reconciled file. The baseline loads a file; a later delta
- * distinguishes a newly reconciled path from a rewritten one, which `set` and
- * `replace` already separate at the producer.
- * @param action - the durable change action.
- * @param baseline - whether this context is the startup/resume baseline.
- * @returns the key naming what happened to that file.
- */
-function instructionAction(
- action: InstructionChange['action'],
- baseline: boolean,
-): 'message.context.instructions.removed' | 'message.context.instructions.loaded'
- | 'message.context.instructions.added' | 'message.context.instructions.updated' {
- if (action === 'remove') return 'message.context.instructions.removed'
- if (baseline) return 'message.context.instructions.loaded'
- return action === 'set' ? 'message.context.instructions.added' : 'message.context.instructions.updated'
-}
-
-/**
- * `instructions` form: the files this context reconciled, then their text.
- *
- * The text keeps its `` framing verbatim — the framing is part
- * of what the model read, so hiding it would misreport the request.
- * @param props - Durable content, its source, and the locale seat.
- * @returns The instructions context body, or the opaque body when the change
- * list is unreadable.
- */
-export function InstructionsBody({ content, source, t }: {
- content: ContextMessageNode['content']
- source: unknown
- t: Translate
-}): ReactNode {
- const changes = instructionChanges(source)
- if (changes === null) return
- const baseline = asRecord(source)?.['baseline'] === true
- return (
- <>
-
-
- >
- )
-}
-
-/** One catalog entry, as the durable source records it. */
-interface CatalogEntry {
- name: string
- description: string
-}
-
-/**
- * Catalog entries read off the source, or null when the record is not a usable
- * catalog. All-or-nothing for the same reason as the instruction list: this body
- * replaces the model-facing text, so a partial list would hide the only complete
- * account of what the model read.
- */
-function catalogEntries(source: unknown): CatalogEntry[] | null {
- const record = asRecord(source)
- const list = record === null ? undefined : record['entries']
- if (!Array.isArray(list)) return null
- const entries: CatalogEntry[] = []
- for (const item of list as readonly unknown[]) {
- const entry = asRecord(item)
- if (entry === null) return null
- const name = entry['name']
- const description = entry['description']
- if (typeof name !== 'string' || name === '' || typeof description !== 'string') return null
- entries.push({ name, description })
- }
- // An empty list is a real catalog: a replacement with no entries retires
- // every earlier name. Only an unreadable shape falls back.
- return entries
-}
-
-/**
- * `catalog` form: the published entries as a list, read from the source rather
- * than re-parsed out of the model-facing prose.
- *
- * A catalog whose source carries no usable entries falls through to the opaque
- * body, so an older or hand-edited log still shows its text.
- * @param props - Durable content, its source, and the locale seat.
- * @returns The catalog context body, or the opaque body when the entry list is
- * unreadable.
- */
-export function CatalogBody({ content, source, t }: {
- content: ContextMessageNode['content']
- source: unknown
- t: Translate
-}): ReactNode {
- const entries = catalogEntries(source)
- if (entries === null) return
- const update = asRecord(source)?.['update'] === true
- // Entry count is unbounded (a provider may publish any number of skills), and
- // the scrollport bounds height, not node count — so the list bounds itself.
- const shown = entries.slice(0, MAX_ENTRIES)
- const rest = unknownBlocks(content)
- return (
- <>
- {update &&
{t('message.context.catalog.replaced')}
}
-
- {shown.map((entry, index) => (
- // Index key: a hand-edited or foreign log may repeat a name, and a
- // duplicate React key would drop a row the model did see.
-
- )}
- {/* The block union is merge-extensible: a catalog message carrying an
- unknown block still shows it rather than dropping model-visible content. */}
-
- >
- )
-}
-
-/** One named contribution to a runtime snapshot, as the durable source records it. */
-interface SnapshotSection {
- name: string
- text: string
-}
-
-/** Snapshot sections read off the source, or null when the record is unusable. */
-function snapshotSections(source: unknown): SnapshotSection[] | null {
- const record = asRecord(source)
- const list = record === null ? undefined : record['sections']
- if (!Array.isArray(list)) return null
- const sections: SnapshotSection[] = []
- for (const item of list as readonly unknown[]) {
- const section = asRecord(item)
- if (section === null) return null
- const name = section['name']
- const text = section['text']
- if (typeof name !== 'string' || name === '' || typeof text !== 'string') return null
- sections.push({ name, text })
- }
- return sections.length === 0 ? null : sections
-}
-
-/**
- * `snapshot` form: the named contributions this snapshot assembled, in order.
- *
- * The sections are the same bytes the model read, split at the boundaries the
- * producer assembled them on, so a reader sees which subsystem contributed
- * which state instead of one undifferentiated wall.
- *
- * One sentence of the model-facing text is NOT in any section: the producer's
- * framing line declaring that this snapshot supersedes earlier ones. Unlike the
- * `` wrapper an instruction context carries — which wraps
- * content and cannot be separated from it — that line states the form's own
- * semantics, so the body states them as a caption instead of reprinting the
- * joined prose beside the sections it was split from.
- * @param props - Durable content, its source, and the locale seat.
- * @returns The snapshot context body, or the opaque body when unreadable.
- */
-export function SnapshotBody({ content, source, t }: {
- content: ContextMessageNode['content']
- source: unknown
- t: Translate
-}): ReactNode {
- const sections = snapshotSections(source)
- /* v8 ignore next -- contextBody reads the sections before choosing this body. */
- if (sections === null) return
- return (
- <>
-
- {t('message.context.snapshot.supersedes')}
-
-
- {sections.map((section, index) => (
-
-
{section.name}
-
{boundedText(section.text, t)}
-
- ))}
-
- >
- )
-}
-
-/**
- * `notice` form: what just happened, with the model-facing text beneath it.
- *
- * The one-line account also rides the collapsed row ({@link contextBody}), so a
- * notice is usually readable without expanding at all.
- * @param props - Durable content, its source, and the locale seat.
- * @returns The notice context body.
- */
-export function NoticeBody({ content, t }: {
- content: ContextMessageNode['content']
- source: unknown
- t: Translate
-}): ReactNode {
- return
-}
-
-/**
- * `relay` form: which agent sent this, then what it said.
- *
- * The sender is an opaque session id; it is shown as a field rather than a
- * label, because this client cannot resolve it to a title.
- * @param props - Durable content, its source, and the locale seat.
- * @returns The relay context body.
- */
-export function RelayBody({ content, source, t }: {
- content: ContextMessageNode['content']
- source: unknown
- t: Translate
-}): ReactNode {
- const sender = relaySender(source)
- /* v8 ignore next -- contextBody resolves the sender before choosing this body. */
- if (sender === null) return
- return (
- <>
-
-
- >
- )
-}
-
-/** The sending agent's session id, or null when the record does not name one. */
-function relaySender(source: unknown): string | null {
- const sender = asRecord(source)?.['senderSessionId']
- return typeof sender === 'string' && sender !== '' ? sender : null
-}
-
-/** One recalled session, as the durable source records it. */
-interface RecalledSession {
- label: string
- retained: number
- omitted: number
- truncated: boolean
-}
-
-/** Recalled sessions read off the source, or null when the record is unusable. */
-function recalledSessions(source: unknown): RecalledSession[] | null {
- const record = asRecord(source)
- const list = record === null ? undefined : record['references']
- if (!Array.isArray(list)) return null
- const sessions: RecalledSession[] = []
- for (const item of list as readonly unknown[]) {
- const reference = asRecord(item)
- if (reference === null) return null
- const label = reference['label']
- const retained = reference['retainedMessages']
- const omitted = reference['omittedMessages']
- const truncated = reference['truncated']
- // Completeness is the fact this card exists to report, so a reference that
- // cannot state it is not a readable recall — showing the label alone would
- // present a confident card over unknown loss.
- if (typeof label !== 'string' || label === ''
- || typeof retained !== 'number' || typeof omitted !== 'number'
- || typeof truncated !== 'boolean') return null
- sessions.push({ label, retained, omitted, truncated })
- }
- return sessions.length === 0 ? null : sessions
-}
-
-/**
- * `recall` form: which sessions this material came from and how much of each
- * survived the read, then the material itself.
- *
- * Completeness is the fact a reader needs first: recalled context is bounded on
- * the way in, so a card that hid the omitted count would overstate what the
- * model received.
- * @param props - Durable content, its source, and the locale seat.
- * @returns The recall context body, or the opaque body when unreadable.
- */
-export function RecallBody({ content, source, t }: {
- content: ContextMessageNode['content']
- source: unknown
- t: Translate
-}): ReactNode {
- const sessions = recalledSessions(source)
- if (sessions === null) return
- return (
- <>
-