fix(ui-chat,web): isolate process updates and restore lifecycle details

This commit is contained in:
lsdsjy committed 2026-09-18 20:02:15 +08:00
1 parent afc62255a5
commit 50b7dd21a2
88 files changed
+861 -1811

No files matched your search

@@ -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
@@ -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.
@@ -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,让工具和详情在重新打开时保持折叠。分页改变分组首个成员时可能重置展开状态。各工具原有的独立折叠仍可在限高过程区内使用。
+4
View File
@@ -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)
@@ -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 /,
@@ -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"
@@ -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."
@@ -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}}"
@@ -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"
@@ -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)
+1 -1
View File
@@ -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 })
@@ -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"
+1 -5
View File
@@ -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() }
})
+1 -1
View File
@@ -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 })
@@ -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,
+2 -2
View File
@@ -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
+17 -5
View File
@@ -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
<a id="reference-previews"></a>
## 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.
<a id="system-prompt-row"></a>
## 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.
<a id="command-and-failure-rows"></a>
## 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.
-----
<a id="turn-token-usage"></a>
## 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.
<a id="turn-process-folding"></a>
## 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.
+15 -5
View File
@@ -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"
<a id="reference-previews"></a>
## 引用预览
已发送的文件引用及消息日志确认调用的 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 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
<a id="system-prompt-row"></a>
## 系统提示词行
## 系统提示词可见性
每个非空追加的 `system/message` 都拥有一行折叠提示词,包括无 header 窗口起点的完整提示词;同一步骤的 header 不会重复它。Chat 也会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的 `系统提示词`。即使系统文本未变,恢复也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示模型所见的确切文本,并保留其原始换行。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。
所有工作过程展示模式均不显示系统提示词行。其持久化记录仍可在轨迹中查看;Chat 过滤不改变模型输入。
<a id="command-and-failure-rows"></a>
## 指令与失败行
通用指令行在所有生命周期状态中都保留普通指令图标;失败仍通过行状态与摘要明确表达。终止轮次的错误仍是独立的红点提示;模型的中间重试不会创建该提示,达到输出 token 上限时使用琥珀色警告点。
-----
@@ -54,6 +62,8 @@ kind: "package-reference"
-----
Chat 快照构建器维护稳定的步骤分组布局、缓存的活动摘要和轮次页脚展示条件。仅正文变化的流式更新只刷新受影响的分组,不扫描历史分组;渲染位置变化和分页会重建布局,同时保留未变化分组的引用。未知的轮次状态不会把末尾分组标记为完成。整轮生命周期变化通过礼貌播报区域通知读屏器,时钟跳动不会重复播报。
<a id="turn-process-folding"></a>
## 轮次过程折叠
@@ -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 或委派输入仍作为用户消息展示,因为日志无法区分其调用方。
@@ -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);
}
@@ -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<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: 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 (
<dl className={css.fields} data-context-fields>
{rows.map(([key, value]) => (
<div key={key} className={css.field}>
<dt className={css.fieldKey}>{key}</dt>
<dd className={css.fieldValue}>{fieldValue(value, t)}</dd>
</div>
))}
</dl>
)
}
/**
* 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) => (
<JsonBlock
key={index}
label={t('message.unknownBlock')}
payload={block}
truncatedLabel={total => 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 (
<>
<ContextContentBody content={content} t={t} />
<SourceFields source={source} formRendered={false} t={t} />
</>
)
}
/** 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<string>()
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 `<system-reminder>` 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 <OpaqueBody content={content} source={source} t={t} />
const baseline = asRecord(source)?.['baseline'] === true
return (
<>
<ul className={css.files} data-context-files>
{changes.map(change => (
<li key={change.path} className={css.file} title={change.digest}>
<span className={css.filePath}>{change.path}</span>
<span className={css.fileAction}>
{t(instructionAction(change.action, baseline))}
</span>
</li>
))}
</ul>
<ContextContentBody content={content} t={t} />
</>
)
}
/** 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 <OpaqueBody content={content} source={source} t={t} />
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 && <p className={css.catalogNotice} data-context-catalog-update>{t('message.context.catalog.replaced')}</p>}
<ul className={css.entries} data-context-entries>
{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.
<li key={index} className={css.entry}>
<code className={css.entryName}>{entry.name}</code>
<span className={css.entryDescription}>{entry.description}</span>
</li>
))}
</ul>
{shown.length < entries.length && (
<p className={css.catalogNotice} data-context-entries-truncated>
{t('message.context.catalog.more', { count: entries.length - shown.length })}
</p>
)}
{/* The block union is merge-extensible: a catalog message carrying an
unknown block still shows it rather than dropping model-visible content. */}
<UnknownBlocks blocks={rest} t={t} />
</>
)
}
/** 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
* `<system-reminder>` 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 <OpaqueBody content={content} source={source} t={t} />
return (
<>
<p className={css.catalogNotice} data-context-snapshot-supersedes>
{t('message.context.snapshot.supersedes')}
</p>
<dl className={css.sections} data-context-sections>
{sections.map((section, index) => (
<div key={index} className={css.section}>
<dt className={css.sectionName}>{section.name}</dt>
<dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
</div>
))}
</dl>
</>
)
}
/**
* `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 <ContextContentBody content={content} t={t} />
}
/**
* `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 <OpaqueBody content={content} source={source} t={t} />
return (
<>
<p className={css.relaySender} data-context-relay-sender>
{t('message.context.relay.from', { session: sender })}
</p>
<ContextContentBody content={content} t={t} />
</>
)
}
/** 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 <OpaqueBody content={content} source={source} t={t} />
return (
<>
<ul className={css.recalls} data-context-recalls>
{sessions.map((session, index) => (
<li key={index} className={css.recall}>
<span className={css.recallLabel}>{session.label}</span>
<span className={css.recallCounts}>
{t('message.context.recall.counts', {
retained: session.retained,
omitted: session.omitted,
})}
</span>
{session.truncated && (
<span className={css.recallCounts}>{t('message.context.recall.truncated')}</span>
)}
</li>
))}
</ul>
<ContextContentBody content={content} t={t} />
</>
)
}
/** The one-line account a `notice` puts on its collapsed row, when it records one. */
function noticeSummary(source: unknown): string | null {
const summary = asRecord(source)?.['summary']
return typeof summary === 'string' && summary !== '' ? summary : null
}
/**
* Choose the body for one context node.
*
* Returns the form the body actually rendered as, which is not always the
* declared one: a declared form whose fields are unreadable falls back to
* opaque, and the caller labels the row with what it really shows.
* `summary` is the collapsed row's one-line account, which only a `notice`
* records: its whole point is being readable without expanding.
* @param form - the producer-declared form projected onto the node.
* @param props - durable content, its source, and the locale seat.
* @returns the rendered form (null for opaque), its collapsed summary, and its body.
*/
export function contextBody(
form: ContextMessageNode['form'],
props: { content: ContextMessageNode['content']; source: unknown; t: Translate },
): { rendered: KnownContextForm | null; summary: string | null; body: ReactNode } {
const opaque = { rendered: null, summary: null, body: <OpaqueBody {...props} /> }
switch (form) {
case 'instructions':
return instructionChanges(props.source) === null
? opaque
: { rendered: 'instructions', summary: null, body: <InstructionsBody {...props} /> }
case 'catalog':
return catalogEntries(props.source) === null
? opaque
: { rendered: 'catalog', summary: null, body: <CatalogBody {...props} /> }
case 'snapshot':
return snapshotSections(props.source) === null
? opaque
: { rendered: 'snapshot', summary: null, body: <SnapshotBody {...props} /> }
case 'notice': {
const summary = noticeSummary(props.source)
return summary === null
? opaque
: { rendered: 'notice', summary, body: <NoticeBody {...props} /> }
}
case 'relay':
return relaySender(props.source) === null
? opaque
: { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
case 'recall':
return recalledSessions(props.source) === null
? opaque
: { rendered: 'recall', summary: null, body: <RecallBody {...props} /> }
case null:
return opaque
/* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new
KnownContextForm here rather than letting it degrade to opaque silently. */
default: {
const unreachable: never = form
throw new Error(`unreachable context form: ${String(unreachable)}`)
}
}
}
@@ -1,64 +0,0 @@
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px code block cap.
SystemPromptRow reuses this sheet so both disclosures share one body. */
.root {
min-width: 0;
}
.root[data-open] {
padding-bottom: 4px;
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
/* Separator and producer name beside the role title: ToolRow's summary geometry,
so the two disclosure rows keep one 24px rhythm and one separator shape. */
.sep {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.source {
flex: none;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
text-overflow: ellipsis;
white-space: nowrap;
}
/* A notice's one-line account: the reason it rarely needs expanding. */
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
box-sizing: border-box;
/* Indent under the title, which starts at leading (16 + delta) + gap 6. */
width: calc(100% - 22px - var(--dsh-content-font-delta, 0px));
max-height: 141px;
margin: 4px 0 0 calc(22px + var(--dsh-content-font-delta, 0px));
overflow: auto;
padding: 10px 16px 12px 12px;
border: none;
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
/* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */
font: 400 11px/16px var(--ds-font-family-code);
}
@@ -1,71 +0,0 @@
import { useState } from 'react'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { DisclosureRow, IconContextInjectionOutlineRegular, ReferenceIconRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ContextMessageNode } from '../contract/snapshot.ts'
import { contextBody } from './ContextBody.tsx'
import css from './ContextInjectionRow.module.css'
/** Props for the logged non-user message presentation. */
export interface ContextInjectionRowProps {
content: ContextMessageNode['content']
source: ContextMessageNode['source']
/** Role and producer name projected from the durable source. */
producer: ContextMessageNode['producer']
/** Producer-declared information form; null renders the opaque body. */
form: ContextMessageNode['form']
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
/**
* Render logged context with the Tool calls disclosure chrome from Figma.
*
* The header names the role the context plays and, beside it, the producer the
* durable source identifies, so a reader can tell an injected skill catalog
* from a workspace instruction file or a recalled session without expanding.
* The expanded body follows the producer-declared form; an absent or unknown
* form renders the opaque body.
* @param props - Durable content, its projected producer role/name and form, and the locale seat.
* @returns A collapsed context row with a bounded, form-specific body.
*/
export function ContextInjectionRow({ content, source, producer, form, t }: ContextInjectionRowProps) {
const [open, setOpen] = useState(false)
// Resolved rather than declared: a form whose fields are unreadable renders
// the opaque body, and the marker must say what the row actually shows.
const { rendered, summary, body } = contextBody(form, { content, source, t })
return (
<DisclosureRow
className={css.root}
icon={producer.role === 'recall'
? <span data-context-recall-icon><ReferenceIconRegular kind="session" /></span>
: <IconContextInjectionOutlineRegular size={14} />}
chevronClassName={css.chevron}
title={t(producer.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
collapsedContent={producer.label === null ? undefined : (
/* ToolRow's separator shape: an aria-hidden dot, so the accessible name
stays the two readable parts and the two disclosure rows expose one
name shape. A source that names no producer drops the dot with it. */
<>
<span className={css.sep} aria-hidden />
<span className={css.source} data-context-source>{producer.label}</span>
{summary !== null && (
<>
<span className={css.sep} aria-hidden />
<span className={css.summary} data-context-summary>{summary}</span>
</>
)}
</>
)}
keepContentWhenOpen
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<div className={css.body} data-context-injection-body data-context-form={rendered ?? undefined}>
{body}
</div>
</DisclosureRow>
)
}
@@ -6,7 +6,6 @@ import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock, projectUserText,
import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts'
import { CompactionItem } from './CompactionItem.tsx'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
@@ -337,20 +336,6 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
)
})
/** Injected-context keyed Chat renderer. */
export const ContextMessageNodeView = memo(function ContextMessageNodeView({ node, t }: ChatNodeViewProps<'context'>) {
const data = node.data
return (
<ContextInjectionRow
content={data.content}
source={data.source}
producer={data.producer}
form={data.form}
t={t}
/>
)
})
/** Automatic compaction keyed Chat renderer. */
export const CompactionNodeView = memo(function CompactionNodeView({ node, t }: ChatNodeViewProps<'compaction'>) {
return <CompactionItem node={node.data} t={t} />
@@ -17,7 +17,8 @@ function latestCompletedParagraphFirstLine(text: string): string {
const separator = /\r?\n[\t ]*\r?\n/g
while (true) {
const nextParagraph = separator.exec(text)
const paragraphEnd = nextParagraph?.index ?? text.length
const paragraphEnd = nextParagraph === null ? text.length
: nextParagraph.index + nextParagraph[0].indexOf('\n')
const newline = text.indexOf('\n', paragraphStart)
if (newline !== -1 && newline <= paragraphEnd) {
const candidate = text.slice(paragraphStart, newline).trim()
@@ -12,7 +12,7 @@
background: none;
color: var(--dsw-alias-label-secondary);
font: inherit;
font-size: 14px;
font-size: var(--dsh-content-font-size, 14px);
text-align: left;
cursor: pointer;
transition: color 100ms ease;
@@ -10,12 +10,11 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import type { ProcessActivity, ProcessActivitySummary, ProcessRange } from '../contract/step-process.ts'
import { turnProcessAlwaysOpen } from '../contract/turn-process.ts'
import { storedTurnProcessEntry } from '../stores.ts'
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
import {
processActivity, processRanges, processTitle, type ProcessActivity, type ProcessRange,
} from './step-process.ts'
import { processTitle } from './step-process.ts'
import { useSearchableHidden } from './searchable-hidden.ts'
import css from './StepProcessList.module.css'
import flowCss from './ChatView.module.css'
@@ -95,18 +94,26 @@ function useStableLiveProcessTitle(desired: LiveProcessTitle, active: boolean):
/** Ordered chat seats with configurable process defaults between assistant responses. */
export const ChatNodeList = memo(function ChatNodeList({ useChat, expandedSteps, ...seatProps }: ListProps) {
const snapshot = useChat(value => value)
return processRanges(snapshot).flatMap(range => expandedSteps && range.process
const layout = useChat(snapshot => snapshot.stepProcesses.layout)
return layout.flatMap(range => expandedSteps && range.process
? range.seats.map(seat => <ChatNodeSeat key={`${seat.nodeKey}:${seat.assistantPart ?? ''}`} {...seatProps} {...seat} />)
: range.process
? <StepProcess key={range.key} range={range}
nodes={range.seats.map(seat => snapshot.nodes.get(seat.nodeKey) as ChatNode)} {...seatProps} />
? <StepProcessSeat key={range.key} groupKey={range.key} useChat={useChat} {...seatProps} />
: <ChatNodeSeat key={range.key} {...seatProps} {...range.seats[0]} />)
})
function StepProcess({ range, nodes, ...seatProps }: SeatProps & {
const StepProcessSeat = memo(function StepProcessSeat({ groupKey, useChat, ...seatProps }: SeatProps & {
readonly groupKey: string
readonly useChat: ChatViewSlotProps['useChat']
}) {
const group = useChat(snapshot => snapshot.stepProcesses.get(groupKey))
return group === undefined ? null : <StepProcess {...group} {...seatProps} />
})
function StepProcess({ range, nodes, summary, ...seatProps }: SeatProps & {
readonly range: ProcessRange
readonly nodes: readonly ChatNode[]
readonly summary: ProcessActivitySummary
}) {
const { useChatNodeProcess, useStore, actions, t } = seatProps
const [open, setOpen] = useState(false)
@@ -149,7 +156,6 @@ function StepProcess({ range, nodes, ...seatProps }: SeatProps & {
for (const child of body.children) observer.observe(child)
return () => { observer.disconnect() }
}, [bodyRef, nodes.length, open, syncScrollEdges])
const summary = processActivity(nodes)
const desiredLiveTitle: LiveProcessTitle = {
activity: summary.running ?? 'thinking',
detail: summary.runningDetail,
@@ -1,50 +0,0 @@
import { memo, useState } from 'react'
import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import { DisclosureRow, IconBrowseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { OpaqueBody } from './ContextBody.tsx'
import css from './ContextInjectionRow.module.css'
/** Props for one complete system prompt disclosure. */
export interface SystemPromptRowProps {
/** Complete model-visible prompt text. */
text: string
/** True when the prompt replaced an earlier one from this position in the history. */
update?: boolean
/** The owning view's locale seat. */
t: ChatViewSlotProps['t']
}
/**
* Render one complete system prompt as a collapsed disclosure whose expanded
* body is the same opaque context chrome: 141px code-block scrollport and
* model-facing text with its real line breaks. An in-history update uses the
* same row under its own title.
* @param props - Complete prompt text, whether it is an update, and the locale seat.
* @returns The system-prompt disclosure row.
*/
export function SystemPromptRow({ text, update = false, t }: SystemPromptRowProps) {
const [open, setOpen] = useState(false)
return (
<DisclosureRow
className={css.root}
icon={<IconBrowseOutlineRegular size={14} />}
chevronClassName={css.chevron}
title={t(update ? 'message.systemPromptUpdate' : 'message.systemPrompt')}
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<div className={css.body} data-system-prompt-body>
<OpaqueBody content={[{ type: 'text', text }]} source={null} t={t} />
</div>
</DisclosureRow>
)
}
/** System-prompt keyed Chat renderer. */
export const SystemPromptNodeView = memo(function SystemPromptNodeView({
node, t,
}: Pick<ChatNodeViewProps<'system-prompt'>, 'node' | 't'>) {
return <SystemPromptRow text={node.data.text} update={node.data.update === true} t={t} />
})
@@ -4,7 +4,7 @@
align-items: center;
width: 100%;
min-width: 0;
height: 33px;
height: calc(33px + var(--dsh-content-font-delta, 0px));
padding: 0 0 8px;
border: none;
border-bottom: 0.5px solid var(--dsw-alias-border-l2);
@@ -43,8 +43,8 @@
.label {
min-width: 0;
overflow: hidden;
font-size: 13px;
line-height: 24px;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -3,6 +3,7 @@ import { IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primit
import type { ChatNodeViewProps } from '../contract/slots.ts'
import { turnProcessAlwaysOpen } from '../contract/turn-process.ts'
import { formatLiveRunDuration, formatRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts'
import a11yCss from './accessibility.module.css'
import css from './TurnProcessNodeView.module.css'
/** Turn-level process disclosure controller. */
@@ -37,24 +38,31 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
: reason === 'error' ? t('message.turnProcess.failed')
: duration === undefined ? t('message.turnProcess.worked')
: t('message.turnProcess.took', { duration })
const announcement = running ? t('chat.deepDiving')
: reason === 'aborted' ? t('message.stopped')
: reason === 'error' ? t('message.turnProcess.failed')
: t('message.turnProcess.worked')
return (
<button
type="button"
className={css.root}
data-open={open || undefined}
data-turn-process={node.data.turn}
data-turn-process-messages={node.data.messageCount}
data-turn-process-tool-calls={node.data.toolCallCount}
data-turn-process-subagents={node.data.subagentCount}
disabled={!canCollapse}
aria-expanded={turnProcess.hasContent ? open : undefined}
onClick={(event) => {
event.currentTarget.focus()
turnProcess.setOpen(!open)
}}
>
<span className={css.label}>{label}</span>
{canCollapse && <IconChevronDownOutlineRegular className={css.chevron} />}
</button>
<>
<span className={a11yCss.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">{announcement}</span>
<button
type="button"
className={css.root}
data-open={open || undefined}
data-turn-process={node.data.turn}
data-turn-process-messages={node.data.messageCount}
data-turn-process-tool-calls={node.data.toolCallCount}
data-turn-process-subagents={node.data.subagentCount}
disabled={!canCollapse}
aria-expanded={turnProcess.hasContent ? open : undefined}
onClick={(event) => {
event.currentTarget.focus()
turnProcess.setOpen(!open)
}}
>
<span className={css.label}>{label}</span>
{canCollapse && <IconChevronDownOutlineRegular className={css.chevron} />}
</button>
</>
)
})
@@ -1,12 +1,9 @@
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
import { memo } from 'react'
import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChatNode } from '../contract/chat-nodes.ts'
import type { ChatNodeViewProps, PerformanceUsageInjected, TurnTailOwnerProps } from '../contract/slots.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { TurnUsagePanel } from './TurnUsagePanel.tsx'
import { assistantText } from './turn-assistant.ts'
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
import css from './TurnTailNodeView.module.css'
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'>
@@ -19,24 +16,9 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
}: TurnTailNodeViewProps) {
const detailed = usePerformanceUsage(mode => mode) === 'detailed'
const data = node.data
const hasLaterChatNode = useChat(snapshot =>
snapshot.locations.getTurn(data.turn).some((key) => {
const candidate = snapshot.nodes.get(key) as ChatNode | undefined
return candidate !== undefined && candidate.kind !== 'turn-tail'
&& candidate.kind !== 'turn-max-tokens'
&& candidate.anchorSeq > (data.closing?.finalNode.seq ?? data.seq)
}))
const endsWithResponse = useChat((snapshot) => {
if (snapshot.timeline.turnOrder.at(-1) !== data.turn) return false
const last = snapshot.locations.getTurn(data.turn)
.map(key => snapshot.nodes.get(key) as ChatNode | undefined)
.findLast(candidate => candidate !== undefined && isVisibleChatNode(candidate)
&& candidate.kind !== 'turn-tail' && candidate.kind !== 'turn-process')
if (last?.kind !== 'assistant-step') return false
const block = last.data.blocks.findLast(candidate =>
(candidate.kind !== 'text' && candidate.kind !== 'reasoning') || candidate.text.trim() !== '')
return block !== undefined && hasAssistantReplyContent([block])
})
const hasLaterChatNode = useChat(snapshot => snapshot.stepProcesses.footer(data.turn)?.hasLaterChatNode ?? false)
const endsWithResponse = useChat(snapshot => snapshot.timeline.turnOrder.at(-1) === data.turn
&& (snapshot.stepProcesses.footer(data.turn)?.endsWithResponse ?? false))
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
? node.location.turn
: undefined
@@ -7,6 +7,7 @@ import {
type IconProps,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeViewProps } from '../contract/slots.ts'
import { formatMessageClock } from './message-chrome.ts'
import { ContextContentBody } from './ContextBody.tsx'
import { turnTriggerDetails, type TurnTriggerIcon } from './turn-trigger.ts'
import css from './TurnTriggerNodeView.module.css'
@@ -31,7 +32,7 @@ export function TurnTriggerNodeView({ node, t }: ChatNodeViewProps<'turn-trigger
const details = turnTriggerDetails(node.data)
const TriggerIcon = TRIGGER_ICONS[details.icon]
const date = new Date(node.data.time)
const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
const time = formatMessageClock(node.data.time, t)
return (
<section className={css.root} data-turn-trigger>
<button className={css.header} type="button" aria-expanded={open} aria-controls={bodyId} onClick={() => { setOpen(!open) }}>
@@ -5,10 +5,9 @@ import { NS } from '../locale.ts'
import { AssistantNodeView } from './AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
import {
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
CompactionNodeView, RetryNodeView, TurnErrorNodeView,
TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
} from './MessageItem.tsx'
import { SystemPromptNodeView } from './SystemPromptRow.tsx'
import { TurnProcessNodeView } from './TurnProcessNodeView.tsx'
import { TurnTriggerNodeView } from './TurnTriggerNodeView.tsx'
import { TurnTailNodeView } from './TurnTailNodeView.tsx'
@@ -25,10 +24,6 @@ export function registerChatNodeRenderers(ctx: Context, performanceUsage: Observ
{ name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'turn-trigger', locale: NS }, TurnTriggerNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'system-prompt', locale: NS }, SystemPromptNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
@@ -1,199 +1,6 @@
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
/** Consecutive process material, separated by visible assistant responses and turn boundaries. */
import type { ChatNode } from '../contract/chat-nodes.ts'
import { isRunningTool } from '../contract/chat-nodes.ts'
import type { ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts'
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
/** One stable renderer seat, optionally restricted to part of an assistant node. */
export interface ProcessSeat {
readonly nodeKey: string
readonly assistantPart?: 'reasoning' | 'response'
}
/** A consecutive process range or a standalone transcript row. */
export interface ProcessRange {
readonly key: string
readonly seats: readonly [ProcessSeat, ...ProcessSeat[]]
readonly process: boolean
readonly closed: boolean
readonly turn: number | undefined
}
const INDEPENDENT = new Set(['user', 'steering', 'turn-trigger', 'model-retry', 'turn-error', 'turn-max-tokens', 'turn-tail', 'turn-process'])
/**
* Divide loaded rows without altering durable nodes or response order.
* @param snapshot - current chat publication, including live keyed nodes.
* @returns ranges with stable first-seat keys and response-closed status.
*/
export function processRanges(snapshot: ChatSnapshot): ProcessRange[] {
const ranges: ProcessRange[] = []
let pending: ProcessSeat[] = []
let pendingTurn: number | undefined
const flush = (closed: boolean): void => {
const [first, ...rest] = pending
if (first !== undefined) ranges.push({
key: `${first.nodeKey}:process`, seats: [first, ...rest], process: true, closed, turn: pendingTurn,
})
pending = []
}
for (const key of snapshot.order) {
const node = snapshot.nodes.get(key) as ChatNode | undefined
if (node === undefined || !isVisibleChatNode(node)) continue
const location = node.location
const turn = location.kind === 'turn' || location.kind === 'step' ? location.turn.turn : undefined
if (pending.length > 0 && turn !== pendingTurn) flush(true)
pendingTurn = turn
if (INDEPENDENT.has(node.kind) || turn === undefined) {
// The synthetic whole-turn control must not split an ongoing process range.
if (node.kind === 'turn-process') {
ranges.push({ key, seats: [{ nodeKey: key }], process: false, closed: true, turn })
continue
}
flush(true)
ranges.push({ key, seats: [{ nodeKey: key }], process: false, closed: true, turn })
} else if (node.kind === 'assistant-step') {
if (node.data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '')) {
pending.push({ nodeKey: key, assistantPart: 'reasoning' })
}
if (hasAssistantReplyContent(node.data.blocks)) {
flush(true)
ranges.push({ key, seats: [{ nodeKey: key, assistantPart: 'response' }], process: false, closed: true, turn })
}
} else {
pending.push({ nodeKey: key })
}
}
const lastTurn = pendingTurn === undefined ? undefined : snapshot.timeline.turns.get(pendingTurn)
flush(lastTurn?.status !== 'open')
return ranges
}
/** Tool categories used by abstract process titles. */
export type ProcessActivity = 'read' | 'search' | 'edit' | 'commands' | 'code'
| 'webSearch' | 'webFetch' | 'subagents' | 'plan' | 'questions' | 'tools'
function activity(name: string): ProcessActivity {
if (['read', 'read_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'].includes(name)) return 'read'
if (name === 'grep' || name === 'glob' || name.endsWith('_inspect')) return 'search'
if (['write', 'edit', 'apply_patch'].includes(name)) return 'edit'
if (['bash', 'pwsh', 'exec_command', 'write_stdin'].includes(name) || name.startsWith('terminal_')) return 'commands'
if (name === 'run_code') return 'code'
if (name === 'web_search') return 'webSearch'
if (name === 'web_fetch') return 'webFetch'
if (name === 'subagent' || name.startsWith('subagent_')) return 'subagents'
if (['todo_write', 'create_goal', 'update_goal', 'get_goal'].includes(name)) return 'plan'
if (name === 'ask_user_question' || name === 'request_user_input') return 'questions'
return 'tools'
}
const LIVE_TOOL_DETAIL_MAX_CHARS = 160
const LIVE_TOOL_DETAIL_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
const LIVE_TOOL_DETAIL_KEYS = [
'title', 'description', 'objective', 'task', 'task_name', 'name', 'question', 'questions', 'prompt', 'message',
'command', 'cmd', 'queries', 'query', 'pattern', 'url', 'uri', 'file_path', 'path', 'target', 'action', 'status',
] as const
function normalizeLiveToolDetail(value: unknown): string {
const text = typeof value === 'string'
? value
: Array.isArray(value) && value.every(item => typeof item === 'string')
? value.join(', ')
: ''
const normalized = text.replace(/\s+/g, ' ').trim()
const chars = Array.from(LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized), part => part.segment)
return chars.length <= LIVE_TOOL_DETAIL_MAX_CHARS
? normalized
: `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…`
}
function questionDetail(value: unknown): string {
if (!Array.isArray(value)) return ''
for (const item of value) {
if (item === null || typeof item !== 'object') continue
const detail = normalizeLiveToolDetail(Reflect.get(item, 'question'))
if (detail !== '') return detail
}
return ''
}
function liveReasoningDetail(nodes: readonly ChatNode[]): string {
for (let nodeIndex = nodes.length - 1; nodeIndex >= 0; nodeIndex--) {
const node = nodes[nodeIndex]
if (node?.kind !== 'assistant-step' || node.data.status !== 'running') continue
for (let blockIndex = node.data.blocks.length - 1; blockIndex >= 0; blockIndex--) {
const block = node.data.blocks[blockIndex]
if (block?.kind !== 'reasoning') continue
const paragraphs = block.text.split(/\r?\n[\t ]*\r?\n/)
for (let paragraphIndex = paragraphs.length - 1; paragraphIndex >= 0; paragraphIndex--) {
const detail = normalizeLiveToolDetail(paragraphs[paragraphIndex]?.replaceAll('**', ''))
if (detail !== '') return detail
}
}
}
return ''
}
function liveToolDetail(name: string, argsRaw: string): string {
let args: unknown
try {
args = JSON.parse(argsRaw)
} catch {
// Partial or free-form arguments have no safe one-line task detail.
return normalizeLiveToolDetail(name)
}
if (args === null || typeof args !== 'object') return normalizeLiveToolDetail(name)
for (const key of LIVE_TOOL_DETAIL_KEYS) {
if (key in args) {
const value: unknown = Reflect.get(args, key)
const detail = key === 'questions' ? questionDetail(value) : normalizeLiveToolDetail(value)
if (detail !== '') return detail
}
}
return normalizeLiveToolDetail(name)
}
/**
* Rank categories by distinct call count, breaking ties by first appearance.
* @param nodes - process members, including recursive tools.
* @returns all ranked categories and the latest running tool category and bounded task detail.
*/
export function processActivity(nodes: readonly ChatNode[]): {
counts: readonly { kind: ProcessActivity; count: number }[]
running: ProcessActivity | undefined
runningDetail: string
} {
const counts = new Map<ProcessActivity, number>()
const seen = new Set<string>()
let running: ProcessActivity | undefined
let runningDetail = ''
let runningTime = -Infinity
const visit = (tool: ToolCallBlock): void => {
if (seen.has(tool.callId)) return
seen.add(tool.callId)
const call = isRunningTool(tool) ? tool : tool.call
if (call !== null) {
const kind = activity(call.name)
if (isRunningTool(tool) && tool.time >= runningTime) {
running = kind
runningDetail = liveToolDetail(tool.name, tool.argsRaw)
runningTime = tool.time
}
counts.set(kind, (counts.get(kind) ?? 0) + 1)
}
for (const child of tool.subCalls) visit(child)
}
for (const node of nodes) {
if (node.kind === 'tool-call') visit(node.data.root)
}
if (running === undefined) runningDetail = liveReasoningDetail(nodes)
return {
counts: [...counts].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count),
running,
runningDetail,
}
}
/** Localized titles for projected step-process activity. */
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { ProcessActivitySummary } from '../contract/step-process.ts'
/**
* Compose localized abstract status or the top three completed categories without counts.
@@ -203,9 +10,9 @@ export function processActivity(nodes: readonly ChatNode[]): {
* @returns the secondary disclosure title.
*/
export function processTitle(
summary: ReturnType<typeof processActivity>,
summary: ProcessActivitySummary,
closed: boolean,
t: import('../contract/slots.ts').ChatViewSlotProps['t'],
t: ChatViewSlotProps['t'],
): string {
if (!closed) return t(`message.stepProcess.${summary.running ?? 'thinking'}`)
const labels = summary.counts.slice(0, 3).map(({ kind }) => t(`message.stepProcess.done.${kind}`))
@@ -3,6 +3,7 @@ import type {
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatConversationViewNode } from './chat-nodes.ts'
import type { TurnProcessSpec } from './turn-process.ts'
import type { ChatStepProcessIndex } from './step-process.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProviderMetadataView, AssistantRequestConfig,
@@ -92,6 +93,7 @@ export interface LegacyConversationSlice {
/** Incremental Chat publication with immutable order and stable live keyed readers. */
export interface ChatSnapshot {
readonly stepProcesses: ChatStepProcessIndex
readonly order: readonly string[]
readonly nodes: ChatNodeStore
readonly locations: ChatLocationNodeIndex
@@ -119,6 +121,7 @@ const EMPTY_NODE_PROCESS_SOURCE: ChatNodeProcessSource = {
/** Empty Chat target used before a view builder is registered. */
export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
stepProcesses: { layout: EMPTY_LIST, get: () => undefined, footer: () => undefined },
order: EMPTY_LIST,
nodes: {
get: () => undefined,
@@ -0,0 +1,60 @@
/** Stable step-group layout and per-group presentation published by Chat. */
import type { ChatNode } from './chat-nodes.ts'
/** One stable renderer seat, optionally restricted to part of an assistant node. */
export interface ProcessSeat {
readonly nodeKey: string
readonly assistantPart?: 'reasoning' | 'response'
}
/** A consecutive process range or a standalone transcript row. */
export interface ProcessRange {
readonly key: string
readonly seats: readonly [ProcessSeat, ...ProcessSeat[]]
readonly process: boolean
readonly closed: boolean
readonly turn: number | undefined
}
/** Tool categories used by abstract process titles. */
export type ProcessActivity = 'read' | 'search' | 'edit' | 'commands' | 'code'
| 'webSearch' | 'webFetch' | 'subagents' | 'plan' | 'questions' | 'tools'
/** Ranked tool categories and live detail for one consecutive process range. */
export interface ProcessActivitySummary {
readonly counts: readonly { readonly kind: ProcessActivity; readonly count: number }[]
readonly running: ProcessActivity | undefined
readonly runningDetail: string
}
/** Layout changes only when renderer seats or their grouping change. */
export type StepProcessLayout = Omit<ProcessRange, 'closed'>
/** Current content and status for one stable group key. */
export interface StepProcessGroup {
readonly range: ProcessRange
readonly nodes: readonly ChatNode[]
readonly summary: ProcessActivitySummary
}
/** Footer facts computed from the visible Turn contents. */
export interface TurnFooterPresentation {
readonly hasLaterChatNode: boolean
readonly endsWithResponse: boolean
}
/** Data-layer process projection; content updates preserve unrelated groups. */
export interface ChatStepProcessIndex {
/** Ordered renderer seats; unchanged during content-only streaming. */
readonly layout: readonly StepProcessLayout[]
/**
* @param key - stable group key.
* @returns current group content, or absence after regrouping.
*/
get(key: string): StepProcessGroup | undefined
/**
* @param turn - owning Turn.
* @returns footer facts, or absence without a footer.
*/
footer(turn: number): TurnFooterPresentation | undefined
}
@@ -14,6 +14,7 @@ import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
import { sessionRecallLabels, skillInvocationName } from './event-projection.ts'
import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts'
import { ChatTurnProcessProjector } from './turn-process-presentation.ts'
import { ChatStepProcessProjector, processLayoutChanged } from './step-process-index.ts'
const EMPTY_KEYS: readonly string[] = []
const EMPTY_TURNS: readonly number[] = []
@@ -960,6 +961,7 @@ function partialContributionChanged(
/** Incremental keyed Chat builder registered under the `chat` target. */
export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversationViewNode, ChatSnapshot> {
private readonly stepProcesses = new ChatStepProcessProjector()
private readonly store = new MutableChatNodeStore()
private readonly locations = new MutableChatLocationIndex()
private readonly navigation = new MutableTurnNavigationIndex()
@@ -986,6 +988,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
this.store.replaceProcesses(this.order, this.locations)
this.navigation.rebuild(input.timeline, this.locations, this.store)
this.timeline = input.timeline
this.stepProcesses.replace({ order: this.order, nodes: this.store, timeline: input.timeline })
const snapshot = this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
this.store.publish()
return snapshot
@@ -998,14 +1001,17 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
const upserts = this.skillNames.apply(this.referenceLabels.apply(input.upserts, this.store), this.store)
const processTurns = new Set<number>()
let structural = false
let regroup = false
const contentOnly: ChatConversationViewNode[] = []
for (const node of upserts) {
const previous = this.store.get(node.key)
regroup ||= processLayoutChanged(previous as ChatNode | undefined, node as ChatNode)
const nodeStructural = previous === undefined
|| previous.kind !== node.kind
|| previous.anchorSeq !== node.anchorSeq
|| previous.visibility !== node.visibility
|| locationIdentity(previous.location) !== locationIdentity(node.location)
regroup ||= nodeStructural
structural ||= nodeStructural
if (!nodeStructural) contentOnly.push(node)
if (processPresentationInputChanged(previous as ChatNode | undefined, node as ChatNode, nodeStructural)) {
@@ -1028,6 +1034,12 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
} else {
this.navigation.touch(turnsOf(contentOnly), this.locations, this.store)
}
const lastTurn = input.timeline.turnOrder.at(-1)
regroup ||= lastTurn !== this.timeline?.turnOrder.at(-1)
|| (lastTurn !== undefined && input.timeline.turns.get(lastTurn)?.status !== this.timeline?.turns.get(lastTurn)?.status)
const processInput = { order: this.order, nodes: this.store, timeline: input.timeline }
if (regroup) this.stepProcesses.replace(processInput)
else this.stepProcesses.update(processInput, upserts.map(node => node.key))
this.timeline = input.timeline
const snapshot = this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
this.store.publish()
@@ -1040,6 +1052,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
): ChatSnapshot {
return {
order: this.order,
stepProcesses: this.stepProcesses,
nodes: this.store,
locations: this.locations,
navigation: this.navigation,
@@ -0,0 +1,135 @@
/** Incremental process content over the ordered Chat renderer seats. */
import type { ChatNode } from '../contract/chat-nodes.ts'
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
import type { ChatSnapshot } from '../contract/snapshot.ts'
import type {
ChatStepProcessIndex, ProcessRange, StepProcessGroup, StepProcessLayout, TurnFooterPresentation,
} from '../contract/step-process.ts'
import { processActivity, processRanges } from './step-process.ts'
type ProcessInput = Pick<ChatSnapshot, 'order' | 'nodes' | 'timeline'>
function sameSeats(left: StepProcessLayout, right: StepProcessLayout | undefined): boolean {
return right !== undefined && left.key === right.key && left.process === right.process && left.turn === right.turn
&& left.seats.length === right.seats.length && left.seats.every((seat, index) =>
seat.nodeKey === right.seats[index]?.nodeKey && seat.assistantPart === right.seats[index].assistantPart)
}
function lastContentKind(node: ChatNode | undefined): string | undefined {
if (node?.kind !== 'assistant-step') return node?.kind
return node.data.blocks.findLast(block =>
(block.kind !== 'text' && block.kind !== 'reasoning') || block.text.trim() !== '')?.kind
}
/**
* Recognize changes to grouping or footer eligibility, excluding body-only chunks.
* @param previous - previously published Node.
* @param next - current Node.
* @returns whether ordered ranges and footer facts need rebuilding.
*/
export function processLayoutChanged(previous: ChatNode | undefined, next: ChatNode): boolean {
if (previous === undefined || previous.kind !== next.kind || previous.visibility !== next.visibility
|| previous.anchorSeq !== next.anchorSeq || previous.location.kind !== next.location.kind) return true
const left = previous.location
const right = next.location
if ((left.kind === 'turn' || left.kind === 'step') && (right.kind === 'turn' || right.kind === 'step')
&& (left.turn.turn !== right.turn.turn || left.turn.status !== right.turn.status)) return true
if (previous.kind === 'turn-tail' && next.kind === 'turn-tail') return previous.data.closing !== next.data.closing
if (previous.kind !== 'assistant-step' || next.kind !== 'assistant-step') return false
return hasAssistantReplyContent(previous.data.blocks) !== hasAssistantReplyContent(next.data.blocks)
|| previous.data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '')
!== next.data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '')
|| lastContentKind(previous) !== lastContentKind(next)
}
/** Owns stable layout, cached activity, and footer facts for one Chat snapshot builder. */
export class ChatStepProcessProjector implements ChatStepProcessIndex {
layout: readonly StepProcessLayout[] = []
private groups = new Map<string, StepProcessGroup>()
private readonly membership = new Map<string, string[]>()
private footers = new Map<number, TurnFooterPresentation>()
get(key: string): StepProcessGroup | undefined { return this.groups.get(key) }
footer(turn: number): TurnFooterPresentation | undefined { return this.footers.get(turn) }
/**
* Rebuild after pagination or a renderer-seat boundary change, preserving unchanged groups.
* @param input - ordered Nodes and their current Turn lifecycle.
*/
replace(input: ProcessInput): void {
const ranges = processRanges(input)
const groups = new Map<string, StepProcessGroup>()
this.membership.clear()
for (const range of ranges) {
if (!range.process) continue
groups.set(range.key, this.project(range, input))
for (const seat of range.seats) {
const keys = this.membership.get(seat.nodeKey) ?? []
keys.push(range.key)
this.membership.set(seat.nodeKey, keys)
}
}
this.groups = groups
if (ranges.length !== this.layout.length || ranges.some((range, index) => !sameSeats(range, this.layout[index]))) {
this.layout = ranges
}
this.replaceFooters(input)
}
/**
* Refresh only groups containing changed content; historical groups keep their identity.
* @param input - current keyed Nodes.
* @param keys - keys changed by this publication.
*/
update(input: ProcessInput, keys: readonly string[]): void {
const dirty = new Set<string>()
for (const key of keys) for (const group of this.membership.get(key) ?? []) dirty.add(group)
for (const key of dirty) {
const previous = this.groups.get(key)
if (previous === undefined) throw new Error(`process membership references missing group ${key}`)
this.groups.set(key, this.project(previous.range, input))
}
}
private project(range: ProcessRange, input: ProcessInput): StepProcessGroup {
const nodes = range.seats.map(seat => input.nodes.get(seat.nodeKey) as ChatNode)
const previous = this.groups.get(range.key)
const sameNodes = previous !== undefined && previous.nodes.length === nodes.length
&& previous.nodes.every((node, index) => node === nodes[index])
const sameRange = previous !== undefined && sameSeats(previous.range, range) && previous.range.closed === range.closed
if (sameNodes && sameRange) return previous
return {
range: sameRange ? previous.range : range,
nodes: sameNodes ? previous.nodes : nodes,
summary: sameNodes ? previous.summary : processActivity(nodes),
}
}
private replaceFooters(input: ProcessInput): void {
const turns = new Map<number, ChatNode[]>()
for (const key of input.order) {
const node = input.nodes.get(key) as ChatNode
const location = node.location
if (location.kind !== 'turn' && location.kind !== 'step') continue
const nodes = turns.get(location.turn.turn) ?? []
nodes.push(node)
turns.set(location.turn.turn, nodes)
}
const footers = new Map<number, TurnFooterPresentation>()
for (const [turn, nodes] of turns) {
const tail = nodes.find(node => node.kind === 'turn-tail')
if (tail?.kind !== 'turn-tail') continue
const hasLaterChatNode = nodes.some(node => node.kind !== 'turn-tail' && node.kind !== 'turn-max-tokens'
&& node.anchorSeq > (tail.data.closing?.finalNode.seq ?? tail.data.seq))
const last = nodes.findLast(node => isVisibleChatNode(node) && node.kind !== 'turn-tail' && node.kind !== 'turn-process')
const block = last?.kind === 'assistant-step' ? last.data.blocks.findLast(candidate =>
(candidate.kind !== 'text' && candidate.kind !== 'reasoning') || candidate.text.trim() !== '') : undefined
const endsWithResponse = block !== undefined && hasAssistantReplyContent([block])
const previous = this.footers.get(turn)
footers.set(turn, previous?.hasLaterChatNode === hasLaterChatNode && previous.endsWithResponse === endsWithResponse
? previous : { hasLaterChatNode, endsWithResponse })
}
this.footers = footers
}
}
@@ -0,0 +1,175 @@
/** Consecutive process material, separated by visible assistant responses and turn boundaries. */
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
import type { ProcessSeat, ProcessRange, ProcessActivity, ProcessActivitySummary } from '../contract/step-process.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import { isRunningTool } from '../contract/chat-nodes.ts'
import type { ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts'
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
const INDEPENDENT = new Set(['user', 'steering', 'turn-trigger', 'model-retry', 'turn-error', 'turn-max-tokens', 'turn-tail', 'turn-process'])
/**
* Divide loaded rows without altering durable nodes or response order.
* @param snapshot - current chat publication, including live keyed nodes.
* @returns ranges with stable first-seat keys and response-closed status.
*/
export function processRanges(snapshot: Pick<ChatSnapshot, 'order' | 'nodes' | 'timeline'>): ProcessRange[] {
const ranges: ProcessRange[] = []
let pending: ProcessSeat[] = []
let pendingTurn: number | undefined
const flush = (closed: boolean): void => {
const [first, ...rest] = pending
if (first !== undefined) ranges.push({
key: `${first.nodeKey}:process`, seats: [first, ...rest], process: true, closed, turn: pendingTurn,
})
pending = []
}
for (const key of snapshot.order) {
const node = snapshot.nodes.get(key) as ChatNode | undefined
if (node === undefined || !isVisibleChatNode(node)) continue
const location = node.location
const turn = location.kind === 'turn' || location.kind === 'step' ? location.turn.turn : undefined
if (pending.length > 0 && turn !== pendingTurn) flush(true)
pendingTurn = turn
if (INDEPENDENT.has(node.kind) || turn === undefined) {
// The synthetic whole-turn control must not split an ongoing process range.
if (node.kind === 'turn-process') {
ranges.push({ key, seats: [{ nodeKey: key }], process: false, closed: true, turn })
continue
}
flush(true)
ranges.push({ key, seats: [{ nodeKey: key }], process: false, closed: true, turn })
} else if (node.kind === 'assistant-step') {
if (node.data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '')) {
pending.push({ nodeKey: key, assistantPart: 'reasoning' })
}
if (hasAssistantReplyContent(node.data.blocks)) {
flush(true)
ranges.push({ key, seats: [{ nodeKey: key, assistantPart: 'response' }], process: false, closed: true, turn })
}
} else {
pending.push({ nodeKey: key })
}
}
const lastTurn = pendingTurn === undefined ? undefined : snapshot.timeline.turns.get(pendingTurn)
flush(lastTurn?.status === 'closed')
return ranges
}
function activity(name: string): ProcessActivity {
if (['read', 'read_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'].includes(name)) return 'read'
if (name === 'grep' || name === 'glob' || name.endsWith('_inspect')) return 'search'
if (['write', 'edit', 'apply_patch'].includes(name)) return 'edit'
if (['bash', 'pwsh', 'exec_command', 'write_stdin'].includes(name) || name.startsWith('terminal_')) return 'commands'
if (name === 'run_code') return 'code'
if (name === 'web_search') return 'webSearch'
if (name === 'web_fetch') return 'webFetch'
if (name === 'subagent' || name.startsWith('subagent_')) return 'subagents'
if (['todo_write', 'create_goal', 'update_goal', 'get_goal'].includes(name)) return 'plan'
if (name === 'ask_user_question' || name === 'request_user_input') return 'questions'
return 'tools'
}
const LIVE_TOOL_DETAIL_MAX_CHARS = 160
const LIVE_TOOL_DETAIL_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
const LIVE_TOOL_DETAIL_KEYS = [
'title', 'description', 'objective', 'task', 'task_name', 'name', 'question', 'questions', 'prompt', 'message',
'command', 'cmd', 'queries', 'query', 'pattern', 'url', 'uri', 'file_path', 'path', 'target', 'action', 'status',
] as const
function normalizeLiveToolDetail(value: unknown): string {
const text = typeof value === 'string'
? value
: Array.isArray(value) && value.every(item => typeof item === 'string')
? value.join(', ')
: ''
const normalized = text.replace(/\s+/g, ' ').trim()
const chars = Array.from(LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized), part => part.segment)
return chars.length <= LIVE_TOOL_DETAIL_MAX_CHARS
? normalized
: `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…`
}
function questionDetail(value: unknown): string {
if (!Array.isArray(value)) return ''
for (const item of value) {
if (item === null || typeof item !== 'object') continue
const detail = normalizeLiveToolDetail(Reflect.get(item, 'question'))
if (detail !== '') return detail
}
return ''
}
function liveReasoningDetail(nodes: readonly ChatNode[]): string {
for (let nodeIndex = nodes.length - 1; nodeIndex >= 0; nodeIndex--) {
const node = nodes[nodeIndex]
if (node?.kind !== 'assistant-step' || node.data.status !== 'running') continue
for (let blockIndex = node.data.blocks.length - 1; blockIndex >= 0; blockIndex--) {
const block = node.data.blocks[blockIndex]
if (block?.kind !== 'reasoning') continue
const paragraphs = block.text.split(/\r?\n[\t ]*\r?\n/)
for (let paragraphIndex = paragraphs.length - 1; paragraphIndex >= 0; paragraphIndex--) {
const detail = normalizeLiveToolDetail(paragraphs[paragraphIndex]?.replaceAll('**', ''))
if (detail !== '') return detail
}
}
}
return ''
}
function liveToolDetail(name: string, argsRaw: string): string {
let args: unknown
try {
args = JSON.parse(argsRaw)
} catch {
// Partial or free-form arguments have no safe one-line task detail.
return normalizeLiveToolDetail(name)
}
if (args === null || typeof args !== 'object') return normalizeLiveToolDetail(name)
for (const key of LIVE_TOOL_DETAIL_KEYS) {
if (key in args) {
const value: unknown = Reflect.get(args, key)
const detail = key === 'questions' ? questionDetail(value) : normalizeLiveToolDetail(value)
if (detail !== '') return detail
}
}
return normalizeLiveToolDetail(name)
}
/**
* Rank categories by distinct call count, breaking ties by first appearance.
* @param nodes - process members, including recursive tools.
* @returns all ranked categories and the latest running tool category and bounded task detail.
*/
export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySummary {
const counts = new Map<ProcessActivity, number>()
const seen = new Set<string>()
let running: ProcessActivity | undefined
let runningDetail = ''
let runningTime = -Infinity
const visit = (tool: ToolCallBlock): void => {
if (seen.has(tool.callId)) return
seen.add(tool.callId)
const call = isRunningTool(tool) ? tool : tool.call
if (call !== null) {
const kind = activity(call.name)
if (isRunningTool(tool) && tool.time >= runningTime) {
running = kind
runningDetail = liveToolDetail(tool.name, tool.argsRaw)
runningTime = tool.time
}
counts.set(kind, (counts.get(kind) ?? 0) + 1)
}
for (const child of tool.subCalls) visit(child)
}
for (const node of nodes) {
if (node.kind === 'tool-call') visit(node.data.root)
}
if (running === undefined) runningDetail = liveReasoningDetail(nodes)
return {
counts: [...counts].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count),
running,
runningDetail,
}
}
@@ -51,8 +51,6 @@ export const zh = {
'chat.loadOlder': '加载更早',
'chat.toBottom': '回到底部',
'chat.deepDiving': '深度求索中',
'chat.deepDivingWithTime': '深度求索中,用时',
'chat.deepDivingTimeSeparator': '',
'chat.turnNavigation.label': '轮次导航',
'chat.turnNavigation.jump': '跳转到第 {turn} 轮',
'chat.turnNavigation.jumpLoad': '加载并跳转到第 {turn} 轮',
@@ -69,22 +67,8 @@ export const zh = {
'fileOpen.title': '无法打开文件',
'fileOpen.unknown': '无法打开此文件',
'message.extraBlock': '附加内容块',
'message.systemPrompt': '系统提示词',
'message.systemPromptUpdate': '系统提示词更新',
'message.contextInjection': '上下文注入',
'message.contextRecall': '跨会话召回',
'message.referenceSummary': '引用会话 · {labels}',
'message.referenceSeparator': '、',
'message.context.instructions.loaded': '已载入',
'message.context.instructions.added': '已新增',
'message.context.instructions.updated': '已更新',
'message.context.instructions.removed': '已移除',
'message.context.catalog.replaced': '替换目录',
'message.context.catalog.more': '…还有 {count} 条',
'message.context.snapshot.supersedes': '取代先前的快照',
'message.context.relay.from': '来自会话 {session}',
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
'message.context.recall.truncated': '已截断',
'message.compaction': '上下文已压缩',
'message.compaction.running': '正在压缩…',
'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)',
@@ -211,8 +195,6 @@ export const en = {
'chat.loadOlder': 'Load earlier',
'chat.toBottom': 'Back to bottom',
'chat.deepDiving': 'Deep diving...',
'chat.deepDivingWithTime': 'Deep diving for',
'chat.deepDivingTimeSeparator': ' ',
'chat.turnNavigation.label': 'Turn navigation',
'chat.turnNavigation.jump': 'Jump to turn {turn}',
'chat.turnNavigation.jumpLoad': 'Load and jump to turn {turn}',
@@ -229,22 +211,8 @@ export const en = {
'fileOpen.title': 'Couldn’t open file',
'fileOpen.unknown': 'Couldn’t open this file',
'message.extraBlock': 'Extra content block',
'message.systemPrompt': 'System prompt',
'message.systemPromptUpdate': 'System prompt update',
'message.contextInjection': 'Context injection',
'message.contextRecall': 'Session recall',
'message.referenceSummary': 'Referenced session · {labels}',
'message.referenceSeparator': ', ',
'message.context.instructions.loaded': 'loaded',
'message.context.instructions.added': 'added',
'message.context.instructions.updated': 'updated',
'message.context.instructions.removed': 'removed',
'message.context.catalog.replaced': 'Replacement catalog',
'message.context.catalog.more': '… {count} more',
'message.context.snapshot.supersedes': 'Supersedes earlier snapshots',
'message.context.relay.from': 'From session {session}',
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
'message.context.recall.truncated': 'truncated',
'message.compaction': 'Context compacted',
'message.compaction.running': 'Compacting context…',
'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)',
@@ -12,7 +12,7 @@ import {
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
} from '../src/client/chat/message-chrome.ts'
import {
CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView,
CompactionNodeView, RetryNodeView, UnknownNodeView,
UserMessageNodeView,
} from '../src/client/chat/MessageItem.tsx'
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
@@ -71,8 +71,6 @@ function MessageItem({ node, t: translate, referenceLabels, skillNames }: Messag
case 'user':
case 'steering':
return <UserMessageNodeView {...props as ChatNodeViewProps<'user' | 'steering'>} />
case 'context':
return <ContextMessageNodeView {...props as ChatNodeViewProps<'context'>} />
case 'compaction':
return <CompactionNodeView {...props as ChatNodeViewProps<'compaction'>} />
case 'model-retry':
@@ -325,482 +323,6 @@ describe('MessageItem arms', () => {
expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
})
it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => {
const ctxView = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'line one\n\nline two' }],
source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
producer: { role: 'inject', label: 'fixture' },
form: null,
} as never}
/>,
)
const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*fixture$/ })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
expect(ctxView.container.querySelector('svg')).not.toBeNull()
expect(ctxView.container.querySelector('[data-context-recall-icon]')).toBeNull()
fireEvent.click(disclosure)
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
// An unknown form renders the opaque body: the model-facing text keeps its
// real line breaks instead of being escaped into one JSON line, and the
// remaining source data follows it as fields.
expect(ctxView.container.querySelector('[data-context-text]')?.textContent)
.toBe('line one\n\nline two')
const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
expect(fields).toEqual(['plugin', 'empty', 'list'])
fireEvent.keyDown(disclosure, { key: ' ' })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
})
it('the instructions form names the files it reconciled above their text', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: '<system-reminder>\nInstructions from: AGENTS.md\n</system-reminder>' }],
source: {
kind: 'agent-instructions',
form: 'instructions',
baseline: true,
changes: [
{ action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' },
{ action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' },
{ action: 'replace', scope: '.\u0000AGENTS.md', path: 'AGENTS.md' },
],
},
producer: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md, sub\/AGENTS\.md$/ }))
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
expect(files).toEqual(['AGENTS.md已载入', 'sub/AGENTS.md已移除'])
// The `<system-reminder>` framing is part of what the model read, so the
// body keeps it verbatim rather than presenting a cleaned-up excerpt.
expect(view.container.querySelector('[data-context-text]')?.textContent)
.toContain('<system-reminder>')
})
it('a delta distinguishes a newly reconciled file from a rewritten one', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'delta' }],
source: {
kind: 'agent-instructions',
form: 'instructions',
changes: [
{ action: 'set', scope: 'a', path: 'new/AGENTS.md' },
{ action: 'replace', scope: 'b', path: 'old/AGENTS.md' },
],
},
producer: { role: 'inject', label: 'new/AGENTS.md, old/AGENTS.md' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*new\/AGENTS\.md, old\/AGENTS\.md$/ }))
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
expect(files).toEqual(['new/AGENTS.md已新增', 'old/AGENTS.md已更新'])
})
it('keeps an interleaved unknown block in the order the model received it', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [
{ type: 'text', text: 'before' },
{ type: 'future-block', payload: 1 },
{ type: 'text', text: 'after' },
],
source: null,
producer: { role: 'inject', label: null },
form: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
const texts = [...view.container.querySelectorAll('[data-context-text]')].map(node => node.textContent)
expect(texts).toEqual(['before', 'after'])
expect(view.getByText(/未知内容块/)).toBeTruthy()
})
it('the catalog form lists its durable entries instead of the model-facing prose', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: '<system-reminder>\n<available_skills>\n- `a`: A\n</available_skills>' }],
source: {
kind: 'skill-catalog',
form: 'catalog',
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill', description: 'Does B' }],
},
producer: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent)
expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B'])
expect(view.container.querySelector('[data-context-text]')).toBeNull()
expect(view.container.querySelector('[data-context-catalog-update]')).toBeNull()
})
it('a replacement catalog says so above its entries', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: {
kind: 'skill-catalog',
form: 'catalog',
update: true,
entries: [{ name: 'a-skill', description: 'Does A' }],
},
producer: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
})
it('a partially unreadable catalog falls back whole rather than showing a short list', () => {
// All-or-nothing: a body that replaces the model-facing text must not show
// a confident, incomplete account of what the model read.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: {
kind: 'skill-catalog',
form: 'catalog',
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill' }],
},
producer: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
// The marker reports what rendered, not what was declared.
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
.toBeNull()
})
it('an unreadable instruction list falls back to the opaque body with its fields', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'instruction prose' }],
source: { kind: 'agent-instructions', form: 'instructions', changes: [{ action: 'set' }] },
producer: { role: 'inject', label: 'agent-instructions' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*agent-instructions$/ }))
expect(view.container.querySelector('[data-context-files]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
})
it('joins adjacent text blocks the way a provider adapter flattens them', () => {
// No invented separator: showing a line break the model never saw would
// misreport the request.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
source: null,
producer: { role: 'inject', label: null },
form: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond')
})
it('bounds an oversized source field, not only the model-facing text', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'short' }],
source: { kind: 'plugin', note: 'y'.repeat(21_000) },
producer: { role: 'inject', label: 'plugin' },
form: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
expect(view.container.querySelector('[data-context-fields] dd')?.textContent)
.toMatch(/… 已截断,共 \d+ 字符$/)
})
it('an empty replacement catalog stays a catalog: it retires every earlier name', () => {
// `renderCatalogUpdate` legitimately publishes zero entries when the last
// skill disappears; falling back would hide that the catalog was cleared.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: { kind: 'skill-catalog', form: 'catalog', update: true, entries: [] },
producer: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(0)
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
.toBe('catalog')
})
it('a catalog whose entries are unreadable falls back to the opaque body', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: { kind: 'skill-catalog', form: 'catalog', entries: 'not-a-list' },
producer: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
})
it('bounds a large catalog and says how many rows it withheld', () => {
const entries = Array.from({ length: 205 }, (_, index) => ({ name: `s-${index}`, description: 'd' }))
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'catalog prose' }],
source: { kind: 'skill-catalog', form: 'catalog', entries },
producer: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(200)
expect(view.container.querySelector('[data-context-entries-truncated]')?.textContent).toBe('…还有 5 条')
})
it('a catalog keeps a content block this version does not know', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'prose' }, { type: 'future-block', payload: 1 }],
source: { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'a', description: 'b' }] },
producer: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.getByText(/未知内容块/)).toBeTruthy()
})
it('an instruction change with an unrecognized action falls back whole', () => {
// The action decides the word the row shows, so an unknown one cannot be
// presented as loaded or updated.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'instruction prose' }],
source: { kind: 'agent-instructions', form: 'instructions', changes: [{ action: 'merge', path: 'A.md' }] },
producer: { role: 'inject', label: 'agent-instructions' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*agent-instructions$/ }))
expect(view.container.querySelector('[data-context-files]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
})
it('the opaque fallback keeps a form declaration this version cannot present', () => {
// Otherwise a newer or foreign log's declared shape vanishes from the UI.
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'x' }],
source: { kind: 'plugin', plugin: 'later', form: 'a-later-form' },
producer: { role: 'inject', label: 'later' },
form: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*later$/ }))
const fields = [...view.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
expect(fields).toEqual(['plugin', 'form'])
})
it('the snapshot form attributes each part to the subsystem that produced it', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'Current runtime context.\n\nsandbox\n\nworkspace' }],
source: {
kind: 'plugin',
plugin: '@deepseek-ai/dsh-system-prompt',
form: 'snapshot',
sections: [{ name: 'sandbox:policy', text: 'workspace-write' }, { name: 'workspace', text: '/repo' }],
},
producer: { role: 'inject', label: '@deepseek-ai/dsh-system-prompt' },
form: 'snapshot',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*@deepseek-ai\/dsh-system-prompt$/ }))
const rows = [...view.container.querySelectorAll('[data-context-sections] div')].map(node => node.textContent)
expect(rows).toEqual(['sandbox:policyworkspace-write', 'workspace/repo'])
})
it('a notice puts its account on the collapsed row', () => {
// The whole point of the form: readable without expanding.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'background job bash-1 finished.' }],
source: { kind: 'plugin', plugin: 'tool-jobs', form: 'notice', summary: 'bash pnpm test [status: completed]' },
producer: { role: 'inject', label: 'tool-jobs' },
form: 'notice',
} as never}
/>,
)
expect(view.container.querySelector('[data-context-summary]')?.textContent)
.toBe('bash pnpm test [status: completed]')
expect(view.container.querySelector('[data-context-injection-body]')).toBeNull()
})
it('a notice without its account falls back to the opaque body', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'notice prose' }],
source: { kind: 'plugin', plugin: 'tool-jobs', form: 'notice' },
producer: { role: 'inject', label: 'tool-jobs' },
form: 'notice',
} as never}
/>,
)
expect(view.container.querySelector('[data-context-summary]')).toBeNull()
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*tool-jobs$/ }))
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
})
it('each form falls back to the opaque body when its required facts are unreadable', () => {
// The fallback chain is the load-bearing wall: every dedicated form must
// reach it, and the row marker must not claim a form that did not render.
const cases = [
{ form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' },
{ form: 'relay', source: { kind: 'agent-message', form: 'relay' }, label: 'agent-message' },
{ form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' },
] as const
for (const { form, source, label } of cases) {
cleanup()
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: `${form} prose` }],
source, producer: { role: 'inject', label }, form,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) }))
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`)
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
.toBeNull()
}
})
it('a snapshot states the supersession its framing line carries', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'Current runtime context.' }],
source: { kind: 'plugin', form: 'snapshot', sections: [{ name: 'sandbox', text: 'w' }] },
producer: { role: 'inject', label: 'plugin' },
form: 'snapshot',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent)
.toBe('取代先前的快照')
})
it('a relay names the agent that sent it above what it said', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'child report body' }],
source: { kind: 'agent-message', form: 'relay', senderSessionId: 'child-7' },
producer: { role: 'inject', label: 'agent-message' },
form: 'relay',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*agent-message$/ }))
expect(view.container.querySelector('[data-context-relay-sender]')?.textContent).toBe('来自会话 child-7')
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('child report body')
})
it('a recall reports how much of each source session survived the read', () => {
// Recalled context is bounded on the way in, so hiding the omitted count
// would overstate what the model received.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'recalled material' }],
source: {
kind: 'session-reference',
form: 'recall',
version: 1,
references: [
{ label: '重构 loader', retainedMessages: 18, omittedMessages: 42, truncated: true },
{ label: '修 CI', retainedMessages: 3, omittedMessages: 0, truncated: false },
],
},
producer: { role: 'recall', label: '重构 loader, 修 CI' },
form: 'recall',
} as never}
/>,
)
expect(view.container.querySelector('[data-context-recall-icon]')).not.toBeNull()
fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ }))
const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent)
expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条'])
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('recalled material')
})
it('unknown nodes retain the generic JSON row', () => {
const unknownView = render(
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
@@ -19,6 +19,19 @@ function declarationsFrom(source: string, selector: string): string[] {
}
describe('chat flow font-size axis', () => {
it('process titles follow their body and secondary font-size tiers', () => {
expect(declarationsFrom(read('StepProcessList.module.css'), '.title')).toContain(
'font-size: var(--dsh-content-font-size, 14px)',
)
expect(declarationsFrom(read('TurnProcessNodeView.module.css'), '.label')).toEqual(expect.arrayContaining([
'font-size: var(--dsh-content-font-size-secondary, 13px)',
'line-height: calc(24px + var(--dsh-content-font-delta, 0px))',
]))
expect(declarationsFrom(read('TurnProcessNodeView.module.css'), '.root')).toContain(
'height: calc(33px + var(--dsh-content-font-delta, 0px))',
)
})
it('think text reads the secondary tier (one step under the body size)', () => {
const css = read('ReasoningRow.module.css')
expect(declarationsFrom(css, '.summary')).toEqual(expect.arrayContaining([
@@ -44,18 +57,12 @@ describe('chat flow font-size axis', () => {
expect(css).not.toContain('justify-content: flex-end')
})
it('command and context summaries read the secondary tier on the shared row line', () => {
it('command summaries read the secondary tier on the shared row line', () => {
expect(declarationsFrom(read('GenericCommandCard.module.css'), '.summary')).toEqual(expect.arrayContaining([
'font-size: var(--dsh-content-font-size-secondary, 13px)',
'line-height: calc(24px + var(--dsh-content-font-delta, 0px))',
]))
const context = read('ContextInjectionRow.module.css')
for (const selector of ['.source', '.summary']) {
expect(declarationsFrom(context, selector)).toEqual(expect.arrayContaining([
'font-size: var(--dsh-content-font-size-secondary, 13px)',
'line-height: calc(24px + var(--dsh-content-font-delta, 0px))',
]))
}
})
it('the message clock and action glyphs scale with the text they serve', () => {
@@ -96,8 +103,6 @@ describe('chat flow font-size axis', () => {
.toEqual(expect.arrayContaining([`padding: 4px 0 4px ${indent}`]))
expect(declarationsFrom(read('MessageItem.module.css'), '.compactionBody'))
.toEqual(expect.arrayContaining([`padding: 4px 0 4px ${indent}`]))
expect(declarationsFrom(read('ContextInjectionRow.module.css'), '.body'))
.toEqual(expect.arrayContaining([`margin: 4px 0 0 ${indent}`]))
})
it('the usage-details trigger reads the secondary tier like its clock label', () => {
@@ -1,3 +1,4 @@
import { ChatStepProcessProjector } from '../src/client/conversation-nodes/step-process-index.ts'
import type {
AssistantChatData, AssistantMessageNode, ChatConversationViewNode, ChatNode, ChatSnapshot, ConversationNode,
ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore,
@@ -517,9 +518,13 @@ export function chatSnapshotFixture(input: {
&& derived.every((item, index) => sameTurnNavigationItem(kept[index], item))
? kept
: derived
const stepProcesses = previous?.stepProcesses instanceof ChatStepProcessProjector
? previous.stepProcesses : new ChatStepProcessProjector()
stepProcesses.replace({ order, nodes: store, timeline })
for (const data of turnData.values()) data.publish()
store.publish()
return {
stepProcesses,
order,
nodes: store,
locations,
@@ -35,12 +35,11 @@ import { en, zh } from '../src/client/locale.ts'
import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
import {
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
CompactionNodeView, RetryNodeView, TurnErrorNodeView,
TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
} from '../src/client/chat/MessageItem.tsx'
import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx'
import { TurnProcessNodeView } from '../src/client/chat/TurnProcessNodeView.tsx'
import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx'
import { formatLiveRunDuration, formatRunDuration } from '../src/client/chat/message-chrome.ts'
import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts'
@@ -307,7 +306,7 @@ function makeHarness(
case 'steering':
return <UserMessageNodeView {...nodeProps<'user' | 'steering'>()} />
case 'context':
return <ContextMessageNodeView {...nodeProps<'context'>()} />
throw new Error('hidden context reached the Chat renderer')
case 'assistant-step':
return <AssistantNodeView {...nodeProps<'assistant-step'>()} />
case 'command':
@@ -331,7 +330,7 @@ function makeHarness(
case 'turn-process':
return <TurnProcessNodeView {...nodeProps<'turn-process'>()} />
case 'system-prompt':
return <SystemPromptNodeView {...nodeProps<'system-prompt'>()} />
throw new Error('hidden system prompt reached the Chat renderer')
case 'turn-tail':
return (
<TurnTailNodeView
@@ -1576,6 +1575,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
const statuses = view.getAllByRole('status')
expect(statuses.map(status => status.textContent)).toEqual([
'深度求索中',
'已达到输出 token 上限回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
])
expect(view.queryByText('本轮运行失败')).toBeNull()
@@ -1760,7 +1760,7 @@ describe('ChatView', () => {
h.props.t = makeTranslate(en, commonEn)
const view = render(<h.ChatView {...h.props} />)
const toggle = view.getByRole('button', { name: 'Deep diving for 1s' })
expect(view.queryByRole('status')).toBeNull()
expect(view.getByRole('status').getAttribute('aria-live')).toBe('polite')
expect(toggle.querySelector('[data-text-shimmer]')).toBeNull()
expect(toggle.getAttribute('aria-expanded')).toBeNull()
act(() => { vi.advanceTimersByTime(2_000) })
@@ -2437,7 +2437,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
expect(view.queryByRole('status')).toBeNull()
expect(view.getByRole('status').getAttribute('aria-live')).toBe('polite')
})
it('keeps the Tool renderer mounted when a running call settles into log order', () => {
@@ -2498,7 +2498,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
const control = turnProcessControl(view.container)!
expect(control.textContent).toMatch(/^深度求索中,用时2分\d{1,2}秒$/)
expect(view.queryByRole('status')).toBeNull()
expect(view.getByRole('status').getAttribute('aria-live')).toBe('polite')
act(() => {
h.setSession({ testInbox: { 'next-turn': [], 'next-step': [{
id: 'steering-occurrence' as never,
@@ -98,6 +98,23 @@ describe('ReasoningRow', () => {
})
it.each(['\n', '\r\n'])('holds completed paragraph previews across streamed %j line endings', (newline) => {
const renderText = (text: string) => (
<AssistantMarkdown t={t} blocks={[{ kind: 'reasoning', text }]} streaming renderMessageImages={renderMessageImages} />
)
const view = render(renderText(`First paragraph${newline}${newline}Pending`))
expect(view.getByText('First paragraph')).toBeTruthy()
expect(view.queryByText('Pending')).toBeNull()
view.rerender(renderText(`First paragraph${newline}${newline}Second paragraph${newline} ${newline}Third`))
expect(view.getByText('Second paragraph')).toBeTruthy()
expect(view.queryByText('Third')).toBeNull()
view.rerender(renderText(`First paragraph${newline}${newline}Second paragraph${newline} ${newline}Third${newline}More tokens`))
expect(view.getByText('Third')).toBeTruthy()
expect(view.queryByText('More tokens')).toBeNull()
})
it('expands completed reasoning from the Think title', () => {
const view = render(
<AssistantMarkdown
@@ -0,0 +1,89 @@
/** Process projection keeps streaming work local to the affected group. */
import { describe, expect, it, vi } from 'vitest'
import type { ChatNode } from '../src/client/contract/chat-nodes.ts'
import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
import { ChatStepProcessProjector, processLayoutChanged } from '../src/client/conversation-nodes/step-process-index.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
function history(turns: number) {
return chatSnapshotFixture({
nodes: Array.from({ length: turns }, (_, i) => ({
kind: 'assistant' as const, seq: i + 1, time: i, turn: i + 1, step: 1,
blocks: [{ kind: 'reasoning' as const, text: 'Inspect history' }, { kind: 'text' as const, text: 'Done' }],
})),
partial: { turn: turns + 1, step: 1, blocks: [{ kind: 'reasoning', text: 'Inspect current work' }] },
})
}
describe('step process projection', () => {
it.each([10, 1000])('reads only the changed group with %i historical turns', (turns) => {
const snapshot = history(turns)
const index = new ChatStepProcessProjector()
index.replace(snapshot)
const layout = index.layout
const oldGroups = layout.filter(range => range.process).map(range => index.get(range.key)!)
const live = oldGroups.at(-1)!
const node = live.nodes[0]!
expect(node.kind).toBe('assistant-step')
if (node.kind !== 'assistant-step') throw new Error('missing live assistant')
const changed: ChatNode<'assistant-step'> = {
...node, data: { ...node.data, blocks: [{ kind: 'reasoning', text: 'Inspect current work in more detail' }] },
}
expect(processLayoutChanged(node, changed)).toBe(false)
const get = vi.fn((key: string) => key === changed.key ? changed : snapshot.nodes.get(key))
index.update({ ...snapshot, nodes: { ...snapshot.nodes, get } }, [changed.key])
expect(get.mock.calls.map(([key]) => key)).toEqual([changed.key])
expect(index.layout).toBe(layout)
for (const group of oldGroups.slice(0, -1)) expect(index.get(group.range.key)).toBe(group)
expect(index.get(live.range.key)?.summary.runningDetail).toBe('Inspect current work in more detail')
expect(index.get(live.range.key)).not.toBe(live)
})
it('regroups when a response begins and preserves preceding groups', () => {
const fixture = history(2)
const builder = new ChatSnapshotBuilder()
const before = builder.replace({ nodes: fixture.nodes.values(), timeline: fixture.timeline })
const historical = before.stepProcesses.layout.filter(range => range.process).map(range => before.stepProcesses.get(range.key)!)
const live = historical.at(-1)!.nodes[0]!
if (live.kind !== 'assistant-step') throw new Error('missing live assistant')
const response: ChatNode<'assistant-step'> = {
...live, data: { ...live.data, blocks: [...live.data.blocks, { kind: 'text', text: 'Answer' }] },
}
expect(processLayoutChanged(live, response)).toBe(true)
const after = builder.apply({ upserts: [response], timeline: fixture.timeline })
for (const group of historical.slice(0, -1)) expect(after.stepProcesses.get(group.range.key)).toBe(group)
expect(after.stepProcesses.get(historical.at(-1)!.range.key)?.range.closed).toBe(true)
expect(after.stepProcesses.layout.at(-1)?.seats[0]?.assistantPart).toBe('response')
const layout = after.stepProcesses.layout
const updated = builder.apply({ upserts: [{ ...response, data: {
...response.data, blocks: [...live.data.blocks, { kind: 'text', text: 'Answer continued' }],
} }], timeline: fixture.timeline })
expect(updated.stepProcesses.layout).toBe(layout)
})
it('keeps unknown trailing turns live until their end is known', () => {
const snapshot = history(0)
const turn = snapshot.timeline.turns.get(1)!
const index = new ChatStepProcessProjector()
index.replace({ ...snapshot, timeline: {
...snapshot.timeline, turns: new Map([[1, { ...turn, status: 'unknown' }]]),
} })
const key = index.layout.find(range => range.process)!.key
expect(index.get(key)?.range.closed).toBe(false)
index.replace({ ...snapshot, timeline: {
...snapshot.timeline, turns: new Map([[1, { ...turn, status: 'closed' }]]),
} })
expect(index.get(key)?.range.closed).toBe(true)
})
it('drops groups and footer facts when the loaded window is replaced', () => {
const populated = history(2)
const index = new ChatStepProcessProjector()
index.replace(populated)
const key = index.layout.find(range => range.process)!.key
index.replace(chatSnapshotFixture({ nodes: [] }))
expect(index.layout).toEqual([])
expect(index.get(key)).toBeUndefined()
expect(index.footer(1)).toBeUndefined()
})
})
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { ChatNode } from '../src/client/contract/chat-nodes.ts'
import { processActivity, processRanges, processTitle } from '../src/client/chat/step-process.ts'
import { processTitle } from '../src/client/chat/step-process.ts'
import { processActivity, processRanges } from '../src/client/conversation-nodes/step-process.ts'
import { en, zh } from '../src/client/locale.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
import type { ModelRetryNode, RunningToolCall } from '../src/client/contract/snapshot.ts'
@@ -1,62 +0,0 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { ChatNode } from '../src/client/contract/chat-nodes.ts'
import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx'
import { en } from '../src/client/locale.ts'
afterEach(cleanup)
describe('SystemPromptNodeView', () => {
it('mounts the opaque context body only while its row is expanded', () => {
const text = '# Agent rules\n\n- Read first\n- **Act carefully**'
const node: ChatNode<'system-prompt'> = {
key: 'request-prompt:1',
kind: 'system-prompt',
id: '1',
target: 'chat',
anchorSeq: 1,
location: { kind: 'unresolved' },
visibility: 'visible',
data: { text },
}
const { container } = render(<SystemPromptNodeView
node={node}
t={makeTranslate(en)}
/>)
const disclosure = screen.getByRole('button', { name: 'System prompt' })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
expect(container.querySelector('[data-system-prompt-body]')).toBeNull()
expect(container.querySelector('[data-context-text]')).toBeNull()
fireEvent.click(disclosure)
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('[data-system-prompt-body]')).not.toBeNull()
expect(container.querySelector('[data-context-text]')?.textContent).toBe(text)
expect(screen.queryByRole('heading', { name: 'Agent rules' })).toBeNull()
fireEvent.click(disclosure)
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
expect(container.querySelector('[data-system-prompt-body]')).toBeNull()
})
it('titles an in-history prompt update as an update of the same row', () => {
const node: ChatNode<'system-prompt'> = {
key: 'system-message:10',
kind: 'system-prompt',
id: '10',
target: 'chat',
anchorSeq: 10,
location: { kind: 'unresolved' },
visibility: 'visible',
data: { text: '# Updated rules', update: true },
}
const { container } = render(<SystemPromptNodeView node={node} t={makeTranslate(en)} />)
const disclosure = screen.getByRole('button', { name: 'System prompt update' })
fireEvent.click(disclosure)
expect(container.querySelector('[data-context-text]')?.textContent).toBe('# Updated rules')
})
})
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { ChatNodeViewProps } from '../src/client/contract/slots.ts'
import { TurnProcessNodeView } from '../src/client/chat/TurnProcessNodeView.tsx'
import { en, zh } from '../src/client/locale.ts'
afterEach(() => {
cleanup()
vi.useRealTimers()
})
const startedAt = new Date(2026, 8, 18, 12).getTime()
function props(reason: 'running' | 'completed' | 'aborted' | 'error', dictionary = en, setOpen = vi.fn()): ChatNodeViewProps<'turn-process'> {
const spec = {
turn: 1, controlAnchorSeq: 1, processStartSeq: 2, answerAnchorSeq: null,
answerStep: null, inlineReasoning: false, messageCount: 1, toolCallCount: 0, subagentCount: 0,
}
return {
node: {
key: 'turn-process:1', kind: 'turn-process', id: '1', target: 'chat', anchorSeq: 1,
visibility: 'visible', data: spec,
location: {
kind: 'turn',
turn: {
turn: 1, status: reason === 'running' ? 'open' : 'closed', steps: [],
data: { get: () => undefined },
start: { type: 'turn/start', seq: 1, time: startedAt, data: { turn: 1 } },
end: reason === 'running' ? undefined : {
type: 'turn/end', seq: 3, time: startedAt + 5000, data: { turn: 1, reason: { kind: reason } },
},
},
},
},
turnProcess: { spec, foldable: true, hasContent: true, open: true, setOpen },
t: makeTranslate(dictionary),
} as unknown as ChatNodeViewProps<'turn-process'>
}
it.each([
{ dictionary: en, running: 'Deep diving...', completed: 'Worked', aborted: 'Stopped', error: 'Failed' },
{ dictionary: zh, running: '深度求索中', completed: '已完成工作', aborted: '已停止', error: '处理失败' },
])('announces lifecycle transitions independently of timer ticks in $running', ({ dictionary, running, ...labels }) => {
vi.useFakeTimers()
vi.setSystemTime(startedAt + 1000)
const view = render(<TurnProcessNodeView {...props('running', dictionary)} />)
const status = view.getByRole('status')
expect(status.textContent).toBe(running)
expect(status.getAttribute('aria-live')).toBe('polite')
expect(status.getAttribute('aria-atomic')).toBe('true')
expect(status.closest('button')).toBeNull()
const before = view.getByRole('button').textContent
const observer = new MutationObserver(() => {})
observer.observe(status, { childList: true, characterData: true, subtree: true })
try {
act(() => { vi.advanceTimersByTime(2000) })
expect(view.getByRole('button').textContent).not.toBe(before)
expect(view.getByRole('status')).toBe(status)
expect(status.textContent).toBe(running)
expect(observer.takeRecords()).toEqual([])
for (const reason of ['completed', 'aborted', 'error'] as const) {
view.rerender(<TurnProcessNodeView {...props(reason, dictionary)} />)
expect(view.getByRole('status')).toBe(status)
expect(status.textContent).toBe(labels[reason])
expect(vi.getTimerCount()).toBe(0)
}
} finally {
observer.disconnect()
}
})
it.each(['running', 'aborted', 'error'] as const)('keeps %s expanded, disabled, and without a disclosure arrow', (reason) => {
vi.useFakeTimers()
vi.setSystemTime(startedAt + 1000)
const setOpen = vi.fn()
const input = props(reason, en, setOpen)
const view = render(<TurnProcessNodeView {...input} />)
const button = view.getByRole('button') as HTMLButtonElement
expect(button.disabled).toBe(true)
expect(button.getAttribute('aria-expanded')).toBe('true')
expect(button.querySelector('svg')).toBeNull()
fireEvent.click(button)
expect(setOpen).not.toHaveBeenCalled()
view.unmount()
expect(vi.getTimerCount()).toBe(0)
})
it('keeps completed work collapsible with its elapsed time', () => {
const setOpen = vi.fn()
const input = props('completed', en, setOpen)
const view = render(<TurnProcessNodeView {...input} />)
const button = view.getByRole('button', { name: 'Took 5s' }) as HTMLButtonElement
expect(button.disabled).toBe(false)
expect(button.querySelector('svg')).not.toBeNull()
fireEvent.click(button)
expect(setOpen).toHaveBeenCalledWith(false)
})
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, expect, it } from 'vitest'
import { afterEach, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import {
IconAgentPresetOutlineRegular, IconAlarmClockOutlineRegular, IconBranchOutlineRegular,
@@ -10,11 +10,15 @@ import {
import type { ChatNodeViewProps } from '../src/client/contract/slots.ts'
import { TurnTriggerNodeView } from '../src/client/chat/TurnTriggerNodeView.tsx'
import { contextForm, contextProducer } from '../src/client/conversation-nodes/event-projection.ts'
import { zh } from '../src/client/locale.ts'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh, en } from '../src/client/locale.ts'
const t = ((key: keyof typeof zh) => zh[key]) as ChatNodeViewProps['t']
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.useRealTimers()
})
function props(source: unknown, text = 'trigger content'): ChatNodeViewProps<'turn-trigger'> {
return {
@@ -72,3 +76,39 @@ it('shows only the explanation and original model-facing content when expanded',
expect(view.queryByText('tool-jobs')).toBeNull()
expect(view.container.querySelector('[data-context-fields]')).toBeNull()
})
it.each([
{ dictionary: zh, time: new Date(2026, 8, 18, 9, 5), expected: '09:05' },
{ dictionary: en, time: new Date(2026, 8, 18, 9, 5), expected: '09:05' },
{ dictionary: zh, time: new Date(2026, 8, 17, 9, 5), expected: '9月17日 09:05' },
{ dictionary: en, time: new Date(2026, 8, 17, 9, 5), expected: '9/17 09:05' },
{ dictionary: zh, time: new Date(2025, 11, 31, 9, 5), expected: '2025年12月31日 09:05' },
{ dictionary: en, time: new Date(2025, 11, 31, 9, 5), expected: '2025-12-31 09:05' },
])('renders the localized trigger timestamp $expected', ({ dictionary, time, expected }) => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 8, 18, 12))
const input = props({ kind: 'goal' })
const node = { ...input.node, data: { ...input.node.data, time: time.getTime() } }
const view = render(<TurnTriggerNodeView {...input} node={node} t={makeTranslate(dictionary)} />)
expect(view.getByText(expected).getAttribute('datetime')).toBe(time.toISOString())
})
it('preserves interleaved content and bounds long text inside a trigger notice', () => {
const input = props({ kind: 'goal' })
const content = [
{ type: 'text', text: 'first' }, { type: 'text', text: 'second' },
{ type: 'future-block', payload: 'opaque' }, { type: 'text', text: 'x'.repeat(20_001) },
] as unknown as typeof input.node.data.content
const view = render(<TurnTriggerNodeView {...input}
node={{ ...input.node, data: { ...input.node.data, content } }} t={makeTranslate(en)} />)
fireEvent.click(view.getByRole('button'))
const texts = [...view.container.querySelectorAll('[data-context-text]')]
expect(texts[0]?.textContent).toBe('firstsecond')
expect(texts[1]?.textContent).toContain('x'.repeat(20_000))
expect(texts[1]?.textContent).not.toContain('x'.repeat(20_001))
expect(texts[1]?.textContent).toContain('20001')
expect(texts[0]?.nextElementSibling?.textContent).toContain('Unknown content block')
fireEvent.click(view.getByText('Unknown content block', { exact: false }))
expect(texts[0]?.nextElementSibling?.textContent).toContain('opaque')
})
@@ -41,12 +41,10 @@
height: 16px;
border-radius: 50%;
corner-shape: round;
/* The resting thumb stays white against the neutral track. */
background: var(--dsw-static-neutral-00);
background: var(--dsw-alias-label-primary-foreground);
transition: transform 120ms ease;
}
.switch[aria-checked='true'] .thumb {
background: var(--dsw-alias-label-primary-foreground);
transform: translateX(16px);
}
+2 -2
View File
@@ -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-tool/README.md
README.md: 76de046803450fdb300824872c898853e6ccf167
README.zh.md: 3b58b2b538014a75c14a2f33ad43f08786550f16
README.md: 74afdaa1cb752bc273f36362b866a68eb1ca2479
README.zh.md: 1bf843566671588186112dfba1f708b846578540
+1 -1
View File
@@ -43,7 +43,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo
### Built-in views
This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running atomic rows animate their title and summary with the shared text shimmer, and hover darkens that text; settled rows remain static. Expandable rows preview a down chevron while collapsed and keep an up chevron visible while open. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in Code Dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or Code Dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running atomic rows animate their title and summary with the shared text shimmer, and hover darkens that text; settled rows remain static. Expandable rows preview a down chevron while collapsed and keep an up chevron visible while open. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
-----
+1 -1
View File
@@ -43,7 +43,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
### 内置视图
本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始事件字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中的原子工具行使用共享文字扫光动画显示标题与摘要,悬停时文字颜色加深;已完成行保持静态。可展开行在收起时预览向下箭头,展开后持续显示向上箭头。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 Code Dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 Code Dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始事件字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中的原子工具行使用共享文字扫光动画显示标题与摘要,悬停时文字颜色加深;已完成行保持静态。可展开行在收起时预览向下箭头,展开后持续显示向上箭头。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
-----
@@ -30,6 +30,10 @@ export function toolChatSnapshot(
const byKey = new Map(nodes.map(node => [node.key, node]))
const empty: readonly string[] = []
return {
stepProcesses: {
layout: nodes.map(node => ({ key: node.key, seats: [{ nodeKey: node.key }], process: false, turn: undefined })),
get: () => undefined, footer: () => undefined,
},
order: nodes.map(node => node.key),
nodes: {
get: key => byKey.get(key),
@@ -70,6 +70,7 @@ const conversationState: ConversationState = {
const emptyKeys: readonly string[] = []
const emptyNodeSource = { getSnapshot: () => undefined, subscribe: () => () => {} }
const chatState: ChatState = {
stepProcesses: { layout: [], get: () => undefined, footer: () => undefined },
order: emptyKeys,
nodes: {
get: () => undefined,
@@ -68,6 +68,7 @@ const conversationState: ConversationState = {
const emptyKeys: readonly string[] = []
const emptyNodeSource = { getSnapshot: () => undefined, subscribe: () => () => {} }
const chatState: ChatState = {
stepProcesses: { layout: [], get: () => undefined, footer: () => undefined },
order: emptyKeys,
nodes: {
get: () => undefined,
@@ -268,7 +268,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'useProjection: UseProjection',
'useTrajectory: UseTrajectory',
],
keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, turn-trigger, unknown, user, workflow-run',
keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, manual-compaction, model-retry, steering, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, turn-trigger, unknown, user, workflow-run',
hookContext: 'ConversationLocationDataStore<ConversationTurnDataMap> | undefined',
slotInject: 'ChatNodeTurnDataInjected',
declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted',
@@ -276,8 +276,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'client-ui-chat UserMessageNodeView key \'user\'',
'client-ui-chat UserMessageNodeView key \'steering\'',
'client-ui-chat TurnTriggerNodeView key \'turn-trigger\'',
'client-ui-chat ContextMessageNodeView key \'context\'',
'client-ui-chat SystemPromptNodeView key \'system-prompt\'',
'client-ui-chat AssistantNodeView key \'assistant-step\'',
'client-ui-chat CommandNodeView key \'command\'',
'client-ui-chat ManualCompactionNodeView key \'manual-compaction\'',
+3 -2
View File
@@ -6,10 +6,11 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{clock}}"
- button "Copy"
- button "Context injection @deepseek-ai/dsh-system-prompt"
- status: Stopped
- button "Stopped" [disabled] [expanded]
- button "Ran commands" [expanded]
- 'button "Failed Bash Error: tool call aborted" [expanded]'
- text: "IN { \"command\": \"node -e \\\"const fs=require('node:fs'); fs.writeFileSync('started.tmp', 'started'); fs.renameSync('started.tmp', 'started.txt'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted"
- button "Inspect"
@@ -12,22 +12,12 @@
- button "Jump to turn 2"
- group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的
- 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"'
- button "System prompt"
- button "6 tool calls" [expanded]
- button "Context injection goal"
- button "Context injection @deepseek-ai/dsh-system-prompt"
- 'button "Think The user''s goal is: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\" — which translates to: \"Do two turns, each turn output the file structure of a random package. Note that after you finish one turn, directly output the content and stop; our system will help you open another turn, and you just do something similar.\""'
- button "Bash List package directories deterministically"
- button "View goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"
- 'button "Think There''s a `packages` directory. Let me look inside to see what packages are available."'
- button "Bash List packages directory contents"
- button "Think There are 4 packages:"
- button "Failed Bash Randomly pick one package directory"
- 'button "Think shuf is not available on macOS. I''ll use a different approach for random selection. Let me use $RANDOM in bash or use `sort -R` or Python."'
- button "Bash Randomly select one package with Python"
- 'button "Think The random pick for turn 1 is `packages/context/session-reference`. Now I''ll output its file structure. Let me get the file tree."'
- button "Bash List all files in session-reference package"
- 'button "Think Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn."'
- button "Continuing goal {{clock}}":
- text: Continuing goal
- time: {{clock}}
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Ran commands and updated the plan"
- paragraph:
- strong: Turn 1 / 2
- text: — 随机选中的包:
@@ -43,15 +33,12 @@
- button "Bad response"
- button "Branch into a new conversation"
- text: {{clock}}
- button "System prompt"
- button "5 tool calls · 2 messages" [expanded]
- button "Context injection goal"
- 'button "Think This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."'
- button "Bash Randomly select another package with Python"
- 'button "Think The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."'
- button "Bash Re-roll excluding the package from turn 1"
- button "Bash List all files in token-meter package"
- 'button "Think This is turn 2 of 2. I''ve output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."'
- button "Continuing goal {{clock}}":
- text: Continuing goal
- time: {{clock}}
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Ran commands"
- paragraph:
- strong: Turn 2 / 2
- text: — 随机选中的包:
@@ -67,10 +54,9 @@
- text: ,turn 2:
- code: token-meter
- text: )。这一轮到此结束。
- button "View goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"
- button "Updated the plan"
- paragraph: 两个 turn 均已完成,目标达成,标记 goal 为完成。
- button "Update goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"
- 'button "Context injection tool-goal complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"'
- button "Updated the plan"
- paragraph: 两个 turn 都完成了 ✅
- paragraph:
- strong: 总结:
@@ -12,8 +12,11 @@
- button "Jump to turn 2"
- group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的
- 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"'
- button "System prompt"
- button "6 tool calls"
- button "Continuing goal {{clock}}":
- text: Continuing goal
- time: {{clock}}
- status: Worked
- button "Took {{duration}}"
- paragraph:
- strong: Turn 1 / 2
- text: — 随机选中的包:
@@ -29,8 +32,11 @@
- button "Bad response"
- button "Branch into a new conversation"
- text: {{clock}}
- button "System prompt"
- button "5 tool calls · 2 messages"
- button "Continuing goal {{clock}}":
- text: Continuing goal
- time: {{clock}}
- status: Worked
- button "Took {{duration}}"
- paragraph: 两个 turn 都完成了 ✅
- paragraph:
- strong: 总结:
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Stopped
- button "Stopped" [disabled]
- paragraph: partial
- text: Stopped
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Stopped
- button "Stopped" [disabled]
- paragraph: partial
- text: Stopped
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Failed
- button "Failed" [disabled]
- status:
- text: This turn failedAPI key is invalid
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- paragraph: partial
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Failed
- button "Failed" [disabled] [expanded]
- group:
- status: Retried model request (2/2) · {{duration}}
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Worked
- button "Took {{duration}}" [expanded]
- group:
- status: Retried model request (1/5) · {{duration}}
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Worked
- button "Took {{duration}}"
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
- button "Copy"
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- paragraph: partial
- textbox "Message or run a task, / commands, @ files or sessions":
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: "Use the bash tool to run exactly: printf 'MINIMAL_BASH_CARD_OK\\n'. Then reply exactly MINIMAL_PRESET_REQUEST_OK and stop. {{clock}}"
- button "Copy"
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Ran commands" [expanded]
- button "Bash printf 'MINIMAL_BASH_CARD_OK\\n'" [expanded]
+1
View File
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}"
- button "Copy"
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Ran code, ran commands, read files" [expanded]
- button "Think"
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- paragraph: partial
- button "2 queued messages"
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- paragraph: partial
- button "2 queued messages" [disabled] [expanded]
@@ -12,6 +12,7 @@
- button "Continuing goal {{clock}}":
- text: Continuing goal
- time: {{clock}}
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- paragraph: partial
- region "To-dos":
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Stopped
- button "Stopped" [disabled]
- paragraph: partial
- text: Stopped
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Stopped
- button "Stopped" [disabled]
- paragraph: partial
- text: Stopped
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- paragraph: partial
- list:
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- paragraph: partial
- list:
@@ -13,6 +13,7 @@
- button "Jump to turn 3"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Stopped
- button "Stopped" [disabled]
- paragraph: partial
- text: Stopped
@@ -25,6 +26,7 @@
- img "queued.png"
- text: Compare with this screenshot {{clock}}
- button "Copy"
- status: Worked
- button "Took {{duration}}"
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
- button "Copy"
@@ -33,6 +35,7 @@
- button "Branch into a new conversation"
- text: {{clock}} Continue with the queued comparison {{clock}}
- button "Copy"
- status: Worked
- button "Took {{duration}}"
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
- button "Copy"
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- paragraph: partial
- list:
@@ -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."
@@ -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}}"
@@ -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"
@@ -11,6 +11,7 @@
- tab "Trajectory"
- text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}"
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- paragraph: partial
- list:
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}}
- button "Copy"
- status: Worked
- button "Took {{duration}}"
- paragraph: DONE
- button "Copy"
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}}
- button "Copy"
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Analysis completed"
- paragraph: Reading the workspace now.
@@ -10,6 +10,7 @@
- text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}}
- button "Copy"
- tooltip "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- button "Analysis completed"
- paragraph: Reading the workspace now.
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}}
- button "Copy"
- status: Stopped
- button "Stopped" [disabled] [expanded]
- button "Analysis completed"
- paragraph: Reading the workspace now.
@@ -9,6 +9,7 @@
- tab "Trajectory"
- text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}}
- button "Copy"
- status: Worked
- button "Took {{duration}}"
- paragraph: DONE
- button "Copy"