diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml new file mode 100644 index 0000000000..233e1ce72a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-26-code-mode-trajectory-waterfall-spans.md: fe4dcc25dbf211cf69e0d33937cf87a7482852e2 +2026-07-26-code-mode-trajectory-waterfall-spans.zh.md: aaae06b1fca1b5587d06aa7704adec421d2b2c27 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md new file mode 100644 index 0000000000..fe4dcc25db --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md @@ -0,0 +1,31 @@ +# Agent Note: Code Mode sub-calls in the trajectory and waterfall views + +Status: implemented + +English | [中文](2026-07-26-code-mode-trajectory-waterfall-spans.zh.md) + +> Scope: the final PR of the Code Mode UI stack — sub-dispatch rendering in the two non-chat views. Chat nesting is owned by the [sub-call rows note](2026-07-26-code-mode-chat-subcall-rows.md); the timing this consumes is the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md)'s start/settle pair. + +## Problem + +Trajectory and waterfall still rendered a `run_code` turn as one opaque Tool cell / one node-count bar. The chat view got nested sub-rows in the earlier PRs, but the two analytical views — whose whole purpose is structure and timing — showed none of the sub-call structure and none of the per-sub-call wall time the dispatch pair now records. Waterfall sub-spans were deliberately deferred until that pair existed: a span without real timing would have been a lie. + +## Decision + +**Trajectory: `subtool` cells interleaved after their parent Tool cell. Waterfall: real-time sub-lanes under the owning turn row.** + +- **Trajectory**: the layout fold takes the snapshot's `codeDispatches` index; after each Tool cell whose `callId` has dispatches (assistant-block calls, orphan results, and running calls alike), it interleaves one `subtool` cell per sub-dispatch in start order — indexes stay sequential across the interleave. A settled sub-call's duration is its start/settle pair (`durationSeconds(sub.time, sub.callTime)`); a running one shows the em dash, exactly the native in-flight convention. The new cell kind wears a `Sub` tag (business tint) and a 28px indent so nesting reads at a glance. +- **Waterfall**: `deriveSubSpans` folds the dispatch index into per-turn lanes with REAL timing — each parent's dispatch window is first start → last settle, and every lane's offset/width is its fraction of that window, so parallel sub-calls (PR3) visibly overlap. Each lane carries a `timing` provenance tag: `measured` (pair observed), `running` (settle pending — extends to the window end at reduced opacity), or `unknown` (settle-only replay window, `callTime: null` — drawn hollow and titled "duration unknown", never a fabricated 0 ms). Lanes draw under the owning turn's bar row, scaled into a fixed lane budget. +- Both views read `codeDispatches` through the standard snapshot hook — no new wire data, no new stores; replay renders identically to live by construction. + +## Alternatives considered + +**Fold sub-calls into the turn-span node counts (weight the existing bars).** Rejected: it hides exactly the structure this stack exists to show, and node-count weighting is already flagged as a stand-in (deviation ledger #3). + +**A dedicated sub-call panel instead of in-view nesting.** Rejected: the stack's settled UX is nesting under the parent everywhere; a separate panel would diverge from chat and double the selection plumbing. + +**Defer waterfall lanes until the P-III duration-lane redesign.** Rejected: the sub-lane timing is real today (the pair), and the fraction-of-window rendering is independent of whatever the turn-level lanes become; deferring would strand the stack's timing payoff. + +## Consequences + +The waterfall carries the first REAL wall-time rendering in the client (turn bars remain node-count stand-ins — the contrast is deliberate and labeled by hover titles). Trajectory cell indexes now count sub-calls, so `#N` totals grow on Code Mode turns. Specs pin the interleave order and durations, the running em-dash arm, window fractions (offsets/widths), the running-lane extension, the unknown-timing (settle-only) lane, and the rendered lane under the turn row; the built-client Code Mode fixture snapshot additionally pins both tabs' assembled rendering (sub-cells with real +0.8s durations, measured lanes). diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md new file mode 100644 index 0000000000..aaae06b1fc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md @@ -0,0 +1,31 @@ +# Agent Note:trajectory 与 waterfall 视图中的 Code Mode 子调用 + +Status: implemented + +[English](2026-07-26-code-mode-trajectory-waterfall-spans.md) | 中文 + +> 范围:Code Mode UI 堆叠 PR(Pull Request)链的最后一个 PR,涵盖两个非 chat 视图中的子分发渲染。chat 的嵌套归[子调用行 Agent Note](2026-07-26-code-mode-chat-subcall-rows.md)所有;本篇所消费的计时即[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md)的 start/settle 事件对。 + +## 问题 + +trajectory 过去仍把一个 `run_code` 轮次渲染为单个不透明的 Tool 单元格,waterfall 则渲染为一根节点计数条。chat 视图在此前的几个 PR 中已获得嵌套子行,但这两个分析视图(其全部意义恰恰是结构与计时)过去既不显示任何子调用结构,也不显示分发事件对如今已记录的逐子调用墙钟时间。waterfall 的子调用 span 曾被刻意推迟到该事件对存在之后:没有真实计时的 span 就是在撒谎。 + +## 决策 + +**trajectory:`subtool` 单元格穿插在其父 Tool 单元格之后。waterfall:所属轮次行之下、带真实计时的子泳道(sub-lane)。** + +- **trajectory**:布局 fold 接收快照的 `codeDispatches` 索引;凡某个 Tool 单元格的 `callId` 名下存在分发(assistant 块内的调用、孤儿结果与运行中的调用一视同仁),fold 就在该单元格之后按启动顺序为每个子分发穿插一个 `subtool` 单元格,索引在整个穿插序列中保持连续编号。已结算子调用的耗时来自其 start/settle 事件对(`durationSeconds(sub.time, sub.callTime)`);运行中的子调用则显示破折号,与原生的进行中约定完全一致。新增的单元格类型带有 `Sub` 标签(business 色调)与 28px 缩进,嵌套关系一眼可辨。 +- **waterfall**:`deriveSubSpans` 把分发索引折叠成带真实计时的逐轮次泳道:每个父调用的分发窗口为首个 start → 最后一个 settle,每条泳道的偏移/宽度即其在该窗口中的占比,因此并行的子调用(PR3)会肉眼可见地重叠。每条泳道带有 `timing` 来源标记:`measured`(观察到了成对事件)、`running`(settle 未到 — 以较低不透明度延伸至窗口末端)或 `unknown`(回放窗口只含 settle、`callTime: null` — 画成空心并以「duration unknown」为悬停标题,绝不伪造 0 ms)。泳道绘制在所属轮次的条形行之下,并缩放进固定的泳道预算。 +- 两个视图都经由标准的快照 hook 读取 `codeDispatches`:没有新的 wire 数据,也没有新的 store;回放的渲染由构造保证与实时完全一致。 + +## 曾考虑的替代方案 + +**把子调用折入轮次 span 的节点计数(给既有的条加权)。** 否决:它隐藏的恰恰是本堆叠 PR 链存在就是为了展示的结构,而且节点计数加权本就已被标记为占位(偏差账本 #3)。 + +**用专用的子调用面板取代视图内嵌套。** 否决:本堆叠 PR 链已敲定的 UX 是处处嵌套在父级之下;独立面板会与 chat 发生偏差,还会让选中接线翻倍。 + +**把 waterfall 泳道推迟到 P-III 的时长泳道重新设计。** 否决:子泳道的计时如今已是真实的(即那对事件),而按窗口占比的渲染与轮次级泳道将来的形态无关;推迟只会让本堆叠 PR 链的计时收益搁浅。 + +## 后果 + +waterfall 承载了 client 中第一处真实的墙钟时间渲染(轮次条仍是节点计数的占位;这一反差是有意为之,并由悬停标题标注)。trajectory 的单元格索引现在会把子调用计入,因此 Code Mode 轮次上的 `#N` 总数会随之增大。spec 锁定穿插顺序与耗时、运行中的破折号分支、窗口占比(偏移/宽度)、运行中泳道的延伸、unknown 计时(仅 settle)泳道,以及轮次行之下实际渲染出的泳道;构建产物级的 Code Mode fixture 快照另行锁定两个标签页的组装后渲染(带真实 +0.8s 耗时的子单元格、measured 泳道)。 diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml new file mode 100644 index 0000000000..9fd37bcedb --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-26-web-syntax-highlighting-shiki.md: b329e35f1d0ce7b3de454758403a09f67056b5af +2026-07-26-web-syntax-highlighting-shiki.zh.md: 8e9d1f0d0c38ce64bcb5da1262538da762f70b12 diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md new file mode 100644 index 0000000000..b329e35f1d --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md @@ -0,0 +1,32 @@ +# Agent Note: Web client syntax highlighting — synchronous fine-grained shiki + +Status: implemented + +English | [中文](2026-07-26-web-syntax-highlighting-shiki.zh.md) + +> Scope: the web client's one syntax-highlighting system — the dependency ruling, the singleton shape, the token-sheet contract, and the consuming surfaces. Fifth PR of the Code Mode UI stack; the [chat sub-call rows note](../feature/2026-07-26-code-mode-chat-subcall-rows.md) shipped the `run_code` program body this exists to make readable. Styling ground rules are owned by [the web styling ruling](2026-07-19-web-styling-system.md). + +## Problem + +The client rendered every code surface — markdown fences in assistant prose, the `run_code` program body, the details panel's args — as flat monospace text. The stack's primary payload is model-written TypeScript; unhighlighted programs are measurably harder to scan, and the repo already ships shiki-highlighted code on its VitePress site, so the web app was the one code-rendering surface without it. + +## Decision + +**Shiki in its synchronous fine-grained form, as one `ui-primitives` singleton, themed exclusively through CSS custom properties.** + +- **Dependency**: `shiki/core` + `@shikijs/langs`, composed via `createHighlighterCoreSync` with `createJavaScriptRegexEngine({ forgiving: true })` — no oniguruma WASM, no async init, bundle-friendly. Grammar allowlist: `typescript` (embeds JS), `shellscript`, `json` — the languages the harness actually renders; everything else falls back to a geometry-identical plain block, never an error. Prior art: the VitePress site already renders all documentation code through shiki, and TextMate grammars materially beat regex highlighters on TypeScript — the payload that matters here. +- **Singleton**: `ui-primitives/src/markdown/highlight.ts` creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). Engine + grammar construction is a ~120-175ms long task, so the module pre-warms the singleton in a deferred task at plugin boot (the lazy path stays as the correctness fallback), keeping the cost off the render path where a stream's finalize swap would jank. The alias table is a `Map`, not an object: fence info strings are assistant-authored, so a label like `constructor` must miss instead of resolving an inherited property and crashing shiki. The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path. +- **Theming**: shiki's `createCssVariablesTheme` routes every token color through `--shiki-*` custom properties; the VALUES live in a new `ui-theme/styles/shiki.css` token sheet (light on `:root`, dark on `body[data-ds-dark-theme]` — the same cascade as every other sheet), imported by the shell's `base.css` chain. Component CSS stays tokens-only; no literal color ever enters JS or component sheets. Background/foreground alias the existing markdown code-block tokens so highlighted and plain blocks agree. +- **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Output stays plain deliberately — tool output is arbitrary text, and guessing a grammar would mis-highlight more than it helps. + +## Alternatives considered + +**`rehype-highlight`/lowlight.** Runner-up: naturally sync and ~⅓ the bundle, but regex-grammar fidelity on TypeScript is visibly worse, and the repo would then run two highlighter systems (site: shiki, app: highlight.js) with two theming vocabularies. + +**Full `shiki` bundle or the oniguruma WASM engine.** Rejected: the full bundle ships every grammar/theme; WASM needs async loading the sync client boot deliberately avoids. The fine-grained core with three grammars keeps the cost proportional to actual use. + +**Highlight in a worker / async.** Rejected: the payloads are small (programs, fences, args); the synchronous JS engine tokenizes them in microseconds, and async introduces a flash-of-unhighlighted-code plus render-machinery churn for no measured need. + +## Consequences + +One code surface for every consumer — a future surface imports `CodeBlock` and inherits highlighting, theming, and the plain fallback. The bundle grows by the shiki core + three grammars (paid once in `ui-primitives`). Token colors are the first `--shiki-*` sheet; a theme package registering alias overrides extends them like any other token. jsdom specs pin the token-span structure, alias resolution, both fallback arms, and the fence route; the existing built-bundle snapshot and browser e2e cover the assembled path. diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md new file mode 100644 index 0000000000..8e9d1f0d0c --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md @@ -0,0 +1,32 @@ +# Agent Note:web client 的语法高亮——同步细粒度的 shiki + +Status: implemented + +[English](2026-07-26-web-syntax-highlighting-shiki.md) | 中文 + +> 范围:web client 唯一的一套语法高亮体系——依赖裁决、单例形态、token 表契约与各消费表面。本篇是 Code Mode UI 堆叠 PR(Pull Request)链的第五个 PR;[chat 子调用行 Agent Note](../feature/2026-07-26-code-mode-chat-subcall-rows.md)交付了 `run_code` 程序正文,而本体系存在的意义正是让它可读。样式的基本规则归 [Web 样式体系裁决](2026-07-19-web-styling-system.md)所有。 + +## 问题 + +client 过去把每一处代码表面——assistant 正文里的 markdown 围栏代码块、`run_code` 程序正文、details 面板的参数——一律渲染成不带高亮的等宽纯文本。本堆叠 PR 链的主要载荷是模型撰写的 TypeScript;未经高亮的程序扫读起来明显更吃力,而仓库已经在自家 VitePress 站点上交付经 shiki 高亮的代码,于是 web 应用成了唯一不带语法高亮的代码渲染表面。 + +## 决策 + +**采用同步细粒度形态的 shiki,作为 `ui-primitives` 里的一个单例,主题化完全经由 CSS 自定义属性完成。** + +- **依赖**:`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法(grammar)白名单:`typescript`(内嵌 JS)、`shellscript`、`json`——即 harness 实际会渲染的那几种语言;其余一律回退到几何完全一致的纯文本块,绝不报错。先例:VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript(正是此处要紧的载荷)上,TextMate 语法实质性优于正则高亮器。 +- **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。引擎加语法的构建是一次约 120-175ms 的长任务,因此模块在插件启动时用延迟任务预热单例(惰性路径保留为正确性兜底),把这笔开销挪出渲染路径——否则流式 finalize 交换的那一刻会卡顿。别名表用 `Map` 而非对象:fence 信息串由 assistant 撰写,诸如 `constructor` 这样的标签必须落空,而不是解析到继承属性并让 shiki 崩溃。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。 +- **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token;任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。 +- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法,带来的误高亮会多于帮助。 + +## 曾考虑的替代方案 + +**`rehype-highlight`/lowlight。** 屈居次选:天然同步,bundle 体积约为三分之一,但基于正则的语法在 TypeScript 上的保真度肉眼可见地更差,而且仓库将从此同时运行两套高亮体系(站点用 shiki、应用用 highlight.js)、维护两套主题化词汇。 + +**完整的 `shiki` bundle,或 oniguruma WASM 引擎。** 否决:完整 bundle 会带上每一种语法和主题;WASM 需要异步加载,而这正是同步的 client 启动刻意规避的。细粒度 core 加三种语法,让成本与实际用量成正比。 + +**在 worker 中高亮/异步高亮。** 否决:载荷都很小(程序、围栏代码块、参数);同步 JS 引擎微秒级就能把它们 token 化,而异步会引入一段未高亮代码的闪现,外加渲染机制的扰动,却没有任何实测得出的需要。 + +## 后果 + +所有消费方共用同一个代码表面——未来的新表面导入 `CodeBlock` 即继承高亮、主题化与纯文本回退。bundle 的增量是 shiki core 加三种语法(在 `ui-primitives` 中一次性支付)。token 颜色是第一张 `--shiki-*` 表;注册别名覆写的主题包扩展它们的方式与扩展任何其他 token 无异。jsdom spec 锁定 token span 结构、别名解析、两条回退分支与围栏路由;既有的已构建 bundle 快照和浏览器 e2e 覆盖组装后的路径。 diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index 46549179be..6f4085da44 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -5,7 +5,8 @@ // the code-variant parent row titled by the model-authored description, its // three always-visible nested sub-rows (bash through the sample registration, // read through GenericToolCard, the failing read wearing the error state), -// the expanded program body, and details-panel resolution of a sub-callId. +// the expanded program body, details-panel resolution of a sub-callId, and +// the trajectory/waterfall tabs' sub-call cells and timing lanes. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -155,13 +156,20 @@ it('expands the code row into the program body and resolves a sub-row through th boot() await openFixtureSession() - // Expand: the leading control reveals the program verbatim. + // Expand: the leading control reveals the program (shiki-tokenized: the + // text splits into styled spans inside one
tree).
const codeRoot = document.querySelector('[data-variant="code"]')
if (codeRoot === null) throw new Error('code-variant row missing')
const toggle = codeRoot.querySelector('button[aria-expanded]')
if (toggle === null) throw new Error('code row expand control missing')
fireEvent.click(toggle)
- await screen.findByText(/const listing = await tools\.bash/)
+ await waitFor(() => {
+ // Scope to THIS row: the markdown fixture turn also renders shiki pres.
+ const pre = codeRoot.querySelector('pre.shiki')
+ if (pre === null || !(pre.textContent ?? '').includes('const listing = await tools.bash')) {
+ throw new Error('highlighted program body missing under the code row')
+ }
+ })
// Sub-row click → details panel resolves the sub-callId with FULL output.
const nest = document.querySelector('[data-subcalls]')
@@ -186,3 +194,64 @@ it('expands the code row into the program body and resolves a sub-row through th
}
`)
})
+
+it('trajectory and waterfall surface the run_code sub-calls with real timing', async () => {
+ boot()
+ await openFixtureSession()
+
+ // Switch to the trajectory tab (same slot ring the chat view registers in).
+ fireEvent.click(await screen.findByRole('tab', { name: 'Trajectory' }))
+ await waitFor(() => {
+ expect(document.querySelector('[data-kind="subtool"]')).not.toBeNull()
+ }, { timeout: 10_000 })
+ const subCells = [...document.querySelectorAll('[data-kind="subtool"]')]
+ expect({
+ // Three Sub cells nested under the run_code Tool cell, in dispatch order,
+ // each with a real +N.Ns own-duration off the start/settle pair (the
+ // fixture spaces every event 800ms apart — never the em dash).
+ subCells: subCells.map(cell => visibleText(cell)),
+ }).toMatchInlineSnapshot(`
+ {
+ "subCells": [
+ "#53Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
+ "#54Subread · {"path":"notes/demo.txt"}+0.8s",
+ "#55Subread · {"path":"notes/missing.txt"}+0.8s",
+ ],
+ }
+ `)
+
+ // Waterfall: each sub-call draws a measured lane scaled into the parent
+ // turn's dispatch window.
+ fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
+ await waitFor(() => {
+ expect(document.querySelector('[data-subspan]')).not.toBeNull()
+ }, { timeout: 10_000 })
+ const lanes = [...document.querySelectorAll('[data-subspan]')]
+ expect({
+ lanes: lanes.map(lane => ({
+ label: visibleText(lane.querySelector('[class*="subTag"]') ?? lane),
+ title: lane.querySelector('[data-timing]')?.getAttribute('title'),
+ timing: lane.querySelector('[data-timing]')?.getAttribute('data-timing'),
+ })),
+ }).toMatchInlineSnapshot(`
+ {
+ "lanes": [
+ {
+ "label": "bash",
+ "timing": "measured",
+ "title": "bash · 0.80s",
+ },
+ {
+ "label": "read",
+ "timing": "measured",
+ "title": "read · 0.80s",
+ },
+ {
+ "label": "read",
+ "timing": "measured",
+ "title": "read · 0.80s",
+ },
+ ],
+ }
+ `)
+})
diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
index 90eb3e3bee..0e91afcc07 100644
--- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
+++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
@@ -44,7 +44,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
{blocks.map((block, i) => {
switch (block.kind) {
- case 'text': return
+ case 'text': return
case 'reasoning': return
// Tool-call heads render as tool rows in the chat view's grouping pass.
case 'tool-call': return null
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
index 204af4573d..16878ae91e 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
@@ -87,14 +87,9 @@ button.leading {
color: var(--dsw-alias-label-tertiary);
}
-/* The code variant's expanded body is the run_code program: monospace on the
- markdown code-block fill so the program reads as code, not prose. */
-.root[data-variant='code'] .body {
- font-family: var(--ds-font-family-code);
- font-size: 13px;
- line-height: 20px;
- padding: 6px 8px;
- margin-left: 22px;
- border-radius: 6px;
- background: var(--dsw-alias-markdown-code-block);
+/* The code variant's expanded body is the run_code program, rendered through
+ the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
+ this row's concern. */
+.codeBody {
+ margin: 4px 0 4px 22px;
}
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index f1a5ce7440..113241eb5d 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -6,7 +6,7 @@
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
-import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import css from './ToolRow.module.css'
@@ -96,7 +96,9 @@ export function ToolRow({
>
)}
- {open && {body}}
+ {open && (variant === 'code'
+ ?
+ : {body})}
)
}
diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
index 6d998aeff9..3d3c84a646 100644
--- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
@@ -5,6 +5,7 @@
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
+import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
@@ -89,7 +90,7 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
{material.argsRaw !== null && (
Input
- {pretty(material.argsRaw)}
+
)}
diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
index 4751ea815e..2d61edae1c 100644
--- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
@@ -152,7 +152,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(view.getByText('Tool call')).toBeTruthy()
})
- it('expanding the code row reveals the program body verbatim', async () => {
+ it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
const parent = 'call-64'
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
const view = mountApp(b.slots)
@@ -160,7 +160,12 @@ describe('run_code sub-calls through the real chat machinery', () => {
const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
expect(toggle).not.toBeNull()
fireEvent.click(toggle!)
- expect(view.getByText(/const listing = await tools\.bash/)).toBeTruthy()
+ // Shiki splits the program into token spans inside one :
+ // assert the whole text and the highlighted tree rather than one node.
+ const pre = view.container.querySelector('pre.shiki')
+ expect(pre).not.toBeNull()
+ expect(pre!.textContent).toContain('const listing = await tools.bash')
+ expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(3)
})
it('an isError sub-call renders the error state dot exactly like a failed native row', async () => {
diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json
index 7f5c3555bd..9ce2bc8676 100644
--- a/packages/client/ui-primitives/package.json
+++ b/packages/client/ui-primitives/package.json
@@ -20,11 +20,13 @@
},
"license": "BSD-3-Clause",
"dependencies": {
+ "@shikijs/langs": "^4.3.1",
"clsx": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
- "remark-gfm": "^4.0.1"
+ "remark-gfm": "^4.0.1",
+ "shiki": "^4.3.1"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts
index 5eff2e40b2..330a3145bf 100644
--- a/packages/client/ui-primitives/src/index.ts
+++ b/packages/client/ui-primitives/src/index.ts
@@ -17,6 +17,7 @@ export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
+export { CodeBlock } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css
new file mode 100644
index 0000000000..f9b5f67136
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css
@@ -0,0 +1,27 @@
+/* One code-block geometry for highlighted and plain arms: the shiki
+ and the fallback draw identically except for token colors. */
+
+.block :where(pre) {
+ margin: 0;
+ padding: 8px 10px;
+ border-radius: 8px;
+ overflow-x: auto;
+ background: var(--dsw-alias-markdown-code-block);
+ font: var(--dsw-font-markdown-code-block);
+}
+
+/* Shiki inlines its theme background var; route it to the repo token. */
+.block :where(pre.shiki) {
+ background: var(--dsw-alias-markdown-code-block) !important;
+}
+
+.block :where(pre) code {
+ font: inherit;
+ background: none;
+ padding: 0;
+}
+
+.plain {
+ color: var(--dsw-alias-label-primary);
+ white-space: pre;
+}
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
new file mode 100644
index 0000000000..1a6349f1e8
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -0,0 +1,37 @@
+// CodeBlock: one code surface for every consumer — markdown fences, the
+// run_code program body, and the details panel's raw args/output — with
+// shiki highlighting for the registered grammars and an identical-geometry
+// plain fallback for everything else. Shiki emits a single
+// tree of nested spans whose colors are --shiki-* custom properties
+// (token sheets own the values); it produces no scripts or event handlers,
+// so injecting its output is safe by construction.
+
+import { useMemo } from 'react'
+import clsx from 'clsx'
+import { highlightToHtml } from './highlight.ts'
+import css from './CodeBlock.module.css'
+
+export interface CodeBlockProps {
+ /** The source text, rendered verbatim (trailing newline trimmed for display). */
+ code: string
+ /** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
+ lang?: string | undefined
+ /** Extra class merged onto the wrapper (callers position; this component draws). */
+ className?: string | undefined
+}
+
+export function CodeBlock({ code, lang, className }: CodeBlockProps) {
+ const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
+ const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
+ if (html === undefined) {
+ return (
+
+ {trimmed}
+
+ )
+ }
+ // eslint-disable-next-line react/no-danger -- shiki's output is a static
+ // span tree it generated from `code` (no user HTML passes through), the
+ // sanctioned innerHTML consumption path per shiki's own docs.
+ return
+}
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
index 425e3969ab..775978a275 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
@@ -1,6 +1,8 @@
+import { isValidElement } from 'react'
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
+import { CodeBlock } from './CodeBlock.tsx'
import css from './MarkdownText.module.css'
const remarkPlugins = [remarkGfm]
@@ -22,7 +24,9 @@ function sanitizeUrl(url: string): string {
const safeUrl: UrlTransform = url => sanitizeUrl(url)
-const components: Components = {
+/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
+function buildComponents(streaming: boolean): Components {
+ return {
a: ({ href = '', children }) => {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <>{children}>
@@ -42,19 +46,40 @@ const components: Components = {
{children}
),
+ // Fenced blocks route through the shared CodeBlock (shiki for registered
+ // grammars, identical-geometry plain fallback for unknown/absent
+ // languages); inline code keeps the default path (the :not(pre)
+ // rule styles it). While the message streams, the fence renders the
+ // plain arm — retokenizing a growing fence on every chunk is quadratic
+ // main-thread work; the finalize swap highlights it once.
+ pre: ({ children }) => {
+ /* v8 ignore next 2 -- the markdown pipeline always hands `pre` its single `code` element; the undefined arm guards a react-markdown representation change. */
+ const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
+ const raw = child?.props.children
+ // A fence whose content isn't one plain string (e.g. an empty fence)
+ // keeps the stock rather than guessing.
+ if (typeof raw !== 'string') return {children}
+ const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
+ return
+ },
+ }
}
+const staticComponents = buildComponents(false)
+const streamingComponents = buildComponents(true)
+
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
- * @param props - Markdown source text preserved by the session projection.
+ * @param props - Markdown source text preserved by the session projection;
+ * `streaming` renders fences plain (highlighting lands on the finalize swap).
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
*/
-export function MarkdownText({ text }: { text: string }) {
+export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) {
return (
{text}
diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts
new file mode 100644
index 0000000000..1fa50f6d2f
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/highlight.ts
@@ -0,0 +1,82 @@
+/**
+ * The client's ONE syntax highlighter: a synchronous fine-grained shiki core
+ * (JavaScript regex engine — no oniguruma WASM, bundle-friendly) with an
+ * explicit grammar allowlist and a CSS-variables theme. Colors live in the
+ * theme package's token sheets as `--shiki-*` custom properties (light and
+ * dark blocks), never here — the repo's tokens-only styling rule.
+ *
+ * Grammars are the set the harness actually renders: TypeScript programs
+ * (`run_code` bodies; TS pulls in JS via grammar embedding), shell commands,
+ * and JSON payloads. An unknown or absent language falls back to plain text
+ * (no highlighting, still monospace) — never an error.
+ */
+
+import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core'
+import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
+import langTs from '@shikijs/langs/typescript'
+import langBash from '@shikijs/langs/shellscript'
+import langJson from '@shikijs/langs/json'
+import type { HighlighterCore } from 'shiki/core'
+
+/**
+ * Language ids (and aliases) the singleton registers; everything else renders
+ * plain. A Map, not an object: fence info strings are assistant-authored, so
+ * a label like `constructor` or `__proto__` must miss instead of resolving an
+ * inherited property and crashing the renderer inside shiki.
+ */
+const LANG_ALIASES = new Map([
+ ['typescript', 'typescript'],
+ ['ts', 'typescript'],
+ ['tsx', 'typescript'],
+ ['javascript', 'typescript'],
+ ['js', 'typescript'],
+ ['shellscript', 'shellscript'],
+ ['bash', 'shellscript'],
+ ['sh', 'shellscript'],
+ ['shell', 'shellscript'],
+ ['zsh', 'shellscript'],
+ ['json', 'json'],
+ ['jsonc', 'json'],
+])
+
+/** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
+const cssVariablesTheme = createCssVariablesTheme({
+ name: 'css-variables',
+ variablePrefix: '--shiki-',
+ fontStyle: true,
+})
+
+let singleton: HighlighterCore | undefined
+
+/** The synchronous highlighter (one instance per document); pre-warmed below, lazy as the fallback. */
+function highlighter(): HighlighterCore {
+ singleton ??= createHighlighterCoreSync({
+ themes: [cssVariablesTheme],
+ langs: [langTs, langBash, langJson],
+ engine: createJavaScriptRegexEngine({ forgiving: true }),
+ })
+ return singleton
+}
+
+// Engine + grammar construction costs a long task (~120-175ms); building it
+// during the first finalized fence's render would jank exactly when a stream
+// completes. Warm the singleton in a deferred task at module load (= plugin
+// boot) instead; the lazy path above stays as the correctness fallback for a
+// fence that renders before the timer fires. `unref` (Node-only) keeps a
+// non-browser import from pinning the event loop.
+const warmupTimer = setTimeout(() => { highlighter() }, 0)
+;(warmupTimer as { unref?: () => void }).unref?.()
+
+/**
+ * Highlight `code` into shiki's HTML (a single `` tree)
+ * when `lang` maps to a registered grammar; `undefined` means the caller
+ * renders its plain fallback.
+ * @param code - the source text.
+ * @param lang - the language hint (a markdown fence info string or a fixed caller id).
+ * @returns the highlighted HTML, or `undefined` for unknown languages.
+ */
+export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
+ const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
+ if (resolved === undefined) return undefined
+ return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
+}
diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.spec.tsx
new file mode 100644
index 0000000000..a58248afab
--- /dev/null
+++ b/packages/client/ui-primitives/tests/code-block.spec.tsx
@@ -0,0 +1,53 @@
+// @vitest-environment jsdom
+// CodeBlock + the shiki singleton: registered grammars highlight into token
+// spans colored by --shiki-* custom properties; unknown/absent languages take
+// the identical-geometry plain arm; aliases resolve; the trailing newline is
+// display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx
+// alongside the rest of the markdown family.
+
+import { describe, expect, it } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import { afterEach } from 'vitest'
+import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
+import { highlightToHtml } from '../src/markdown/highlight.ts'
+
+afterEach(cleanup)
+
+describe('highlightToHtml', () => {
+ it('highlights a registered grammar into css-variables token spans', () => {
+ const html = highlightToHtml('const x: number = 1', 'typescript')
+ expect(html).toContain('pre class="shiki css-variables"')
+ expect(html).toContain('var(--shiki-')
+ })
+
+ it.each([['ts'], ['js'], ['bash'], ['sh'], ['jsonc']])('resolves the %s alias', (alias) => {
+ expect(highlightToHtml('x', alias)).toContain('shiki')
+ })
+
+ it('returns undefined for unknown or absent languages', () => {
+ expect(highlightToHtml('x', 'cobol')).toBeUndefined()
+ expect(highlightToHtml('x', undefined)).toBeUndefined()
+ })
+})
+
+describe('CodeBlock', () => {
+ it('renders the highlighted tree for TypeScript', () => {
+ const view = render( )
+ const pre = view.container.querySelector('pre.shiki')
+ expect(pre).not.toBeNull()
+ expect(pre!.textContent).toBe('const a = 1')
+ expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(1)
+ })
+
+ it('renders the plain arm for an unknown language with the text verbatim', () => {
+ const view = render( )
+ expect(view.container.querySelector('pre.shiki')).toBeNull()
+ expect(view.getByText('IDENTIFICATION DIVISION.')).toBeTruthy()
+ })
+
+ it('renders the plain arm when no language is given', () => {
+ const view = render( )
+ expect(view.container.querySelector('pre.shiki')).toBeNull()
+ expect(view.getByText('plain text')).toBeTruthy()
+ })
+})
diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx
index 4e1dc292d4..05c7ce0139 100644
--- a/packages/client/ui-primitives/tests/markdown.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown.spec.tsx
@@ -57,11 +57,41 @@ describe('MarkdownText', () => {
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
expect(container.querySelector('hr')).not.toBeNull()
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
+ // The ts fence routed through the shared CodeBlock: shiki token spans present.
+ expect(container.querySelector('pre.shiki')).not.toBeNull()
expect(container.querySelector('br')).not.toBeNull()
expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
})
+ it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => {
+ for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) {
+ const { container, unmount } = render( )
+ expect(container.querySelector('pre.shiki')).toBeNull()
+ expect(container.querySelector('pre code')?.textContent).toContain('code body')
+ unmount()
+ }
+ })
+
+ it('an empty fence keeps the stock pre; a language-less fence renders the plain CodeBlock arm', () => {
+ const empty = render( )
+ expect(empty.container.querySelector('pre')?.outerHTML).toBe('
')
+
+ const plain = render( )
+ expect(plain.container.querySelector('pre.shiki')).toBeNull()
+ expect(plain.container.querySelector('pre code')?.textContent).toContain('no language here')
+ })
+
+ it('streaming renders fences plain; the finalize swap highlights them', () => {
+ const fence = '```ts\nconst answer = 42\n```'
+ const live = render( )
+ expect(live.container.querySelector('pre.shiki')).toBeNull()
+ expect(live.container.querySelector('pre code')?.textContent).toContain('const answer = 42')
+ live.unmount()
+ const done = render( )
+ expect(done.container.querySelector('pre.shiki')).not.toBeNull()
+ })
+
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
const markdown = [
'',
diff --git a/packages/client/ui-theme/src/styles/shiki.css b/packages/client/ui-theme/src/styles/shiki.css
new file mode 100644
index 0000000000..c7a3c5d272
--- /dev/null
+++ b/packages/client/ui-theme/src/styles/shiki.css
@@ -0,0 +1,31 @@
+/* Syntax-highlight token palette: the values behind shiki's css-variables
+ theme (--shiki-* custom properties emitted by the ui-primitives CodeBlock).
+ Light values on :root, dark overrides on the body attribute — the same
+ cascade as every other token sheet. Background/foreground deliberately
+ alias the markdown code-block tokens so highlighted and plain blocks agree. */
+
+:root {
+ --shiki-foreground: var(--dsw-alias-label-primary);
+ --shiki-background: var(--dsw-alias-markdown-code-block);
+ --shiki-token-constant: #1c7ed6;
+ --shiki-token-string: #2f9e44;
+ --shiki-token-comment: #868e96;
+ --shiki-token-keyword: #d6336c;
+ --shiki-token-parameter: #e8590c;
+ --shiki-token-function: #6741d9;
+ --shiki-token-string-expression: #2b8a3e;
+ --shiki-token-punctuation: #495057;
+ --shiki-token-link: #1971c2;
+}
+
+body[data-ds-dark-theme] {
+ --shiki-token-constant: #4dabf7;
+ --shiki-token-string: #69db7c;
+ --shiki-token-comment: #adb5bd;
+ --shiki-token-keyword: #faa2c1;
+ --shiki-token-parameter: #ffa94d;
+ --shiki-token-function: #b197fc;
+ --shiki-token-string-expression: #8ce99a;
+ --shiki-token-punctuation: #ced4da;
+ --shiki-token-link: #74c0fc;
+}
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css
index 1120fe2746..c5efc232d1 100644
--- a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css
+++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css
@@ -61,6 +61,17 @@
background: var(--dsw-alias-state-warn-tertiary);
}
+/* run_code sub-dispatch cells: the business tint plus an indent so the
+ nesting under the parent Tool cell reads at a glance. */
+.tagSubtool {
+ color: var(--dsw-alias-state-business-primary);
+ background: var(--dsw-alias-state-business-tertiary);
+}
+
+.root[data-kind='subtool'] {
+ padding-left: 28px;
+}
+
.text {
flex: 1 1 auto;
min-width: 0;
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx
index de99d027d8..94fc6042a4 100644
--- a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx
+++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx
@@ -4,20 +4,23 @@
import type { HTMLAttributes } from 'react'
import css from './TrajectoryCell.module.css'
-/** Closed set of trajectory step kinds (call+result fold into Tool; no Think). */
-export type TrajectoryCellKind = 'user' | 'message' | 'tool'
+/** Closed set of trajectory step kinds (call+result fold into Tool; no Think;
+ * subtool = one run_code sub-dispatch nested under its Tool cell). */
+export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool'
/** Display label per kind (matches the design tags). */
const KIND_LABEL: Record = {
user: 'User',
message: 'Message',
tool: 'Tool',
+ subtool: 'Sub',
}
const TAG_CLASS: Record = {
user: css.tagUser!,
message: css.tagMessage!,
tool: css.tagTool!,
+ subtool: css.tagSubtool!,
}
export interface TrajectoryCellProps extends HTMLAttributes {
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx
index 45277eb628..3d417b085e 100644
--- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx
+++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx
@@ -12,9 +12,10 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
const nodes = useSession((s) => s.nodes)
const partial = useSession((s) => s.partial)
const runningCalls = useSession((s) => s.runningCalls)
+ const codeDispatches = useSession((s) => s.codeDispatches)
const turns = useMemo(
- () => deriveTrajectoryLayout({ nodes, partial, runningCalls }),
- [nodes, partial, runningCalls],
+ () => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
+ [nodes, partial, runningCalls, codeDispatches],
)
if (turns.length === 0) {
return 暂无轨迹数据
diff --git a/packages/client/ui-trajectory/src/client/WaterfallView.tsx b/packages/client/ui-trajectory/src/client/WaterfallView.tsx
index feeb6a7f16..ad81845fb9 100644
--- a/packages/client/ui-trajectory/src/client/WaterfallView.tsx
+++ b/packages/client/ui-trajectory/src/client/WaterfallView.tsx
@@ -1,16 +1,20 @@
-// WaterfallView: P-I placeholder body for the waterfall tab — span stats
-// header over node-count bars per turn standing in for duration lanes (no
-// timing data yet; deviation ledger #3 defers real rendering to P-III).
+// WaterfallView: span stats header over per-turn node-count lanes (P-I
+// stand-in for duration lanes; deviation ledger #3). run_code turns
+// additionally draw TRUTHFUL sub-call lanes: the dispatch start/settle pair
+// carries per-sub-call wall time, so each sub-span's width is its real
+// duration against the parent turn's dispatch window.
import { useMemo } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { deriveSpans } from './spans.ts'
+import { deriveSpans, deriveSubSpans } from './spans.ts'
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
import css from './views.module.css'
/** Bar width scale: px per node, clamped so tiny windows still show a bar. */
const PX_PER_NODE = 14
const MIN_BAR_PX = 8
+/** Sub-span lane width budget (the parent window scales into this). */
+const SUB_LANE_PX = 220
/** Optional density override (test/standalone knob; the register site passes nothing). */
export interface WaterfallExtraProps {
@@ -21,27 +25,48 @@ export interface WaterfallExtraProps {
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
const scale = pxPerNode ?? PX_PER_NODE
const nodes = useSession((s) => s.nodes)
+ const codeDispatches = useSession((s) => s.codeDispatches)
const spans = useMemo(() => deriveSpans(nodes), [nodes])
+ const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
if (spans.length === 0) return 暂无瀑布数据
return (
<>
{spans.map((span, i) => (
-
- turn {span.turn}
-
- {span.calls > 0 && (
+
+
+ turn {span.turn}
- )}
+ {span.calls > 0 && (
+
+ )}
+
+ {(subSpans.get(span.turn) ?? []).map((lane) => (
+
+ {lane.name}
+
+
+ ))}
))}
diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts
index e188498554..37c86f6eb4 100644
--- a/packages/client/ui-trajectory/src/client/layout.ts
+++ b/packages/client/ui-trajectory/src/client/layout.ts
@@ -4,6 +4,7 @@
*/
import type {
AssistantMessageNode,
+ CodeSubCall,
ConversationSnapshot,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -27,6 +28,8 @@ export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes']
partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls']
+ /** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
+ codeDispatches: ConversationSnapshot['codeDispatches']
}
interface UsageLike {
@@ -49,7 +52,7 @@ interface LaidCell {
* @returns turns ordered by first appearance.
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
- const { nodes, partial, runningCalls } = input
+ const { nodes, partial, runningCalls, codeDispatches } = input
const resultByCall = indexResults(nodes)
const turns = new Map }>()
let index = 0
@@ -96,7 +99,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
if (node.kind === 'assistant') {
- const laidList = expandAssistant(node, index + 1, prevAbsTime, resultByCall)
+ const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches)
for (const laid of laidList) {
if (node.step > 0) pushStep(node.turn, node.step, laid)
else pushMessage(node.turn, laid)
@@ -128,6 +131,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
timeSeconds: durationSeconds(node.time, node.callTime),
},
})
+ for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
+ pushStep(0, 1, laid)
+ index = laid.cell.index
+ }
}
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
}
@@ -161,6 +168,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
timeSeconds: null,
},
})
+ for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
+ pushStep(call.turn, call.step > 0 ? call.step : 1, laid)
+ index = laid.cell.index
+ }
}
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
@@ -387,6 +398,53 @@ function collectCallIds(
return ids
}
+
+
+/** Interleave each tool cell's run_code sub-dispatch cells right after it, reindexing followers. */
+function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot['codeDispatches']): LaidCell[] {
+ if (codeDispatches.size === 0) return laidList
+ const out: LaidCell[] = []
+ let index = laidList[0] !== undefined ? laidList[0].cell.index - 1 : 0
+ for (const laid of laidList) {
+ out.push({ ...laid, cell: { ...laid.cell, index: ++index } })
+ if (laid.callId === undefined) continue
+ for (const sub of expandSubCalls(codeDispatches.get(laid.callId), index)) {
+ out.push(sub)
+ index = sub.cell.index
+ }
+ }
+ return out
+}
+
+/** Sub-dispatch cells for one run_code parent, in start order (running = null duration). */
+function expandSubCalls(
+ subs: readonly CodeSubCall[] | undefined,
+ startIndex: number,
+): LaidCell[] {
+ if (subs === undefined || subs.length === 0) return []
+ const out: LaidCell[] = []
+ let index = startIndex
+ for (const sub of subs) {
+ const settled = 'kind' in sub
+ out.push({
+ absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
+ toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
+ callId: sub.callId,
+ cell: {
+ index: ++index,
+ kind: 'subtool',
+ text: settled
+ ? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub))
+ : summarizeCall(sub.name, sub.argsRaw),
+ // PR3's start/settle pair carries per-sub-call wall time; a running
+ // (unsettled) or pre-pair log entry shows the em dash.
+ timeSeconds: settled ? durationSeconds(sub.time, sub.callTime) : null,
+ },
+ })
+ }
+ return out
+}
+
function summarizeCall(name: string, argsRaw: string): string {
const args = argsRaw.replace(/\s+/g, ' ').trim()
if (args === '') return name
diff --git a/packages/client/ui-trajectory/src/client/spans.ts b/packages/client/ui-trajectory/src/client/spans.ts
index 4957f3762c..585a336333 100644
--- a/packages/client/ui-trajectory/src/client/spans.ts
+++ b/packages/client/ui-trajectory/src/client/spans.ts
@@ -5,6 +5,24 @@
*/
import type { ConversationNode, ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
+/** One run_code sub-dispatch lane in the waterfall: real timing off the start/settle pair. */
+export interface SubSpanLane {
+ callId: string
+ name: string
+ /** Wall duration in ms; null unless both endpoints were observed (`timing: 'measured'`). */
+ durationMs: number | null
+ /**
+ * Timing provenance: `measured` = start/settle pair observed; `running` =
+ * start seen, settle pending; `unknown` = settle-only replay window (the
+ * start fell outside), so no duration claim is possible.
+ */
+ timing: 'measured' | 'running' | 'unknown'
+ /** Start offset as a fraction of the parent turn's dispatch window [0, 1). */
+ offsetFraction: number
+ /** Width as a fraction of the window (running lanes extend to the window end). */
+ widthFraction: number
+}
+
/** One turn's worth of activity, folded from the snapshot node window. */
export interface TurnSpan {
turn: number
@@ -69,3 +87,61 @@ export function deriveSpanStats(spans: readonly TurnSpan[]): SpanStats {
function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } {
return node.kind === 'assistant' || node.kind === 'steering'
}
+
+/**
+ * Fold the dispatch index into per-turn sub-span lanes with REAL timing: each
+ * lane's offset/width scale against its parent turn's dispatch window (first
+ * start → last settle). Running (unsettled) lanes extend to the window end
+ * with a null duration.
+ * @param nodes - snapshot nodes (locates each parent run_code call's turn).
+ * @param codeDispatches - the snapshot's dispatch index.
+ * @returns lanes keyed by turn, in start order.
+ */
+export function deriveSubSpans(
+ nodes: ConversationSnapshot['nodes'],
+ codeDispatches: ConversationSnapshot['codeDispatches'],
+): ReadonlyMap {
+ const out = new Map()
+ if (codeDispatches.size === 0) return out
+ const turnByCall = new Map()
+ let currentTurn = 0
+ for (const node of nodes) {
+ if (node.kind === 'assistant' || node.kind === 'steering') currentTurn = node.turn
+ if (node.kind === 'tool-result') turnByCall.set(node.callId, currentTurn)
+ }
+ for (const [parent, subs] of codeDispatches) {
+ if (subs.length === 0) continue
+ const turn = turnByCall.get(parent) ?? currentTurn
+ // A settle-only entry (callTime null: its start fell outside the replay
+ // window) anchors the window by its settle time — a real observation —
+ // but must never masquerade as a measured zero-duration span.
+ const starts: number[] = []
+ const ends: number[] = []
+ for (const sub of subs) {
+ const settled = 'kind' in sub
+ const start = settled ? sub.callTime ?? sub.time : sub.time
+ starts.push(start)
+ ends.push(settled ? sub.time : start)
+ }
+ const windowStart = Math.min(...starts)
+ const windowEnd = Math.max(...ends, windowStart + 1)
+ const windowSpan = windowEnd - windowStart
+ const lanes: SubSpanLane[] = subs.map((sub, i) => {
+ const settled = 'kind' in sub
+ const timing = settled ? (sub.callTime === null ? 'unknown' as const : 'measured' as const) : 'running' as const
+ const start = starts[i] ?? windowStart
+ const end = settled ? sub.time : windowEnd
+ return {
+ callId: sub.callId,
+ name: settled ? sub.call?.name ?? sub.callId : sub.name,
+ durationMs: timing === 'measured' ? Math.max(0, end - start) : null,
+ timing,
+ offsetFraction: (start - windowStart) / windowSpan,
+ widthFraction: Math.max((end - start) / windowSpan, 0.02),
+ }
+ })
+ const existing = out.get(turn) ?? []
+ out.set(turn, [...existing, ...lanes])
+ }
+ return out
+}
diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css
index d3089b3568..16a853c441 100644
--- a/packages/client/ui-trajectory/src/client/views.module.css
+++ b/packages/client/ui-trajectory/src/client/views.module.css
@@ -45,3 +45,38 @@
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
}
+
+/* run_code sub-span lanes: one row per sub-dispatch under its turn row,
+ offset/width scaled to the dispatch window (real wall time). A running
+ lane pulses via reduced opacity until its settle arrives. */
+.subRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 2px;
+}
+
+.subTag {
+ flex: none;
+ width: 88px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--dsw-alias-label-tertiary);
+ font: var(--dsw-font-xs-13);
+}
+
+.barSub {
+ height: 8px;
+ background: var(--dsw-alias-state-business-primary);
+}
+
+.barSub[data-timing='running'] {
+ opacity: 0.45;
+}
+
+/* Settle-only replay entries: no measured span — hollow, not a solid bar. */
+.barSub[data-timing='unknown'] {
+ background: transparent;
+ border: 1px dashed var(--dsw-alias-state-business-primary);
+}
diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx
index 9773f6fe57..b74782a4c4 100644
--- a/packages/client/ui-trajectory/tests/layout.spec.tsx
+++ b/packages/client/ui-trajectory/tests/layout.spec.tsx
@@ -70,7 +70,7 @@ describe('deriveTrajectoryLayout', () => {
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
- const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+ const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns).toHaveLength(1)
expect(turns[0]?.turn).toBe(1)
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
@@ -86,6 +86,7 @@ describe('deriveTrajectoryLayout', () => {
it('adds runningCalls not already present and leaves their time blank', () => {
const turns = deriveTrajectoryLayout({
+ codeDispatches: new Map(),
nodes: [] as unknown as ConversationSnapshot['nodes'],
partial: null,
runningCalls: [{
@@ -111,7 +112,7 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
- const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+ const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
@@ -137,7 +138,7 @@ describe('deriveTrajectoryLayout', () => {
content: [], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
- const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+ const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
})
@@ -154,7 +155,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'ok2' }],
},
] as unknown as ConversationSnapshot['nodes']
- const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+ const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns.map((t) => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
@@ -168,7 +169,7 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
- const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+ const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
expect(message).toMatchObject({
text: '', input: 11, output: 22, think: 3,
@@ -196,7 +197,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'done' }],
},
] as unknown as ConversationSnapshot['nodes']
- const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+ const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups
.flatMap((g) => g.cells)
.find((c) => c.kind === 'message' && c.text === 'done')
@@ -204,3 +205,51 @@ describe('deriveTrajectoryLayout', () => {
expect(message?.timeSeconds).toBe(1)
})
})
+
+describe('run_code sub-dispatch cells', () => {
+ const runCodeNodes = [
+ {
+ kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
+ blocks: [
+ { kind: 'tool-call', callId: 'p1', name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' },
+ ],
+ },
+ {
+ kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
+ call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
+ content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
+ },
+ ] as unknown as ConversationSnapshot['nodes']
+
+ const settledSub = (n: number, name: string, start: number, end: number) => ({
+ kind: 'tool-result' as const, seq: 100 + n, time: end,
+ callId: `p1:code:${n}`,
+ call: { name, argsRaw: '{"x":1}' }, callTime: start,
+ content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
+ })
+
+ it('nests settled sub-cells after their parent Tool cell with real durations', () => {
+ const codeDispatches = new Map([['p1', [
+ settledSub(1, 'bash', 6_300, 7_300),
+ settledSub(2, 'read', 7_300, 7_800),
+ ]]]) as unknown as ConversationSnapshot['codeDispatches']
+ const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
+ const cells = turns[0]!.groups.flatMap((g) => g.cells)
+ expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
+ // Sequential indexes across the interleave; durations from the pair times.
+ expect(cells.map((c) => c.index)).toEqual([1, 2, 3])
+ expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
+ expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
+ })
+
+ it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
+ const running = {
+ callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
+ turn: 0, step: 0, time: 6_400, callView: null,
+ }
+ const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
+ const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
+ const sub = turns[0]!.groups.flatMap((g) => g.cells).find((c) => c.kind === 'subtool')
+ expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
+ })
+})
diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx
index c1e6331ef6..485db395eb 100644
--- a/packages/client/ui-trajectory/tests/views.spec.tsx
+++ b/packages/client/ui-trajectory/tests/views.spec.tsx
@@ -21,7 +21,7 @@ import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversa
import { ConversationRoot, type ConversationRootProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
-import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
+import { deriveSpans, deriveSpanStats, deriveSubSpans } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
@@ -54,7 +54,7 @@ const NODES = [
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({
- nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
+ nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
}
@@ -117,7 +117,7 @@ function tabsOf(slots: SlotsService): ViewTab[] {
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
const sessionSnapshot = createSnapshotStore({
running: false, removed: false, promptError: null, nodes,
- partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
+ partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession
const chat = createChatStore().create()
@@ -260,3 +260,115 @@ describe('node half', () => {
expect(nodeApply()).toBeUndefined()
})
})
+
+describe('deriveSubSpans (waterfall lanes)', () => {
+ const dispatchNodes = [
+ { kind: 'assistant', seq: 2, time: 6_000, turn: 3, step: 1, blocks: [] },
+ {
+ kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
+ call: { name: 'run_code', argsRaw: '{}' }, callTime: 6_100,
+ content: [], isError: false, callView: null, resultView: null,
+ },
+ ] as unknown as ConversationSnapshot['nodes']
+
+ it('scales settled lanes into the dispatch window with real durations', () => {
+ const codeDispatches = new Map([['p1', [
+ {
+ kind: 'tool-result', seq: 101, time: 7_000, callId: 'p1:code:1',
+ call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
+ content: [], isError: false, callView: null, resultView: null,
+ },
+ {
+ kind: 'tool-result', seq: 102, time: 8_200, callId: 'p1:code:2',
+ call: { name: 'read', argsRaw: '{}' }, callTime: 7_000,
+ content: [], isError: false, callView: null, resultView: null,
+ },
+ ]]]) as unknown as ConversationSnapshot['codeDispatches']
+ const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
+ const turn3 = lanes.get(3)
+ expect(turn3).toHaveLength(2)
+ // Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0.
+ expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, timing: 'measured', offsetFraction: 0 })
+ expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4)
+ expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 })
+ expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4)
+ })
+
+ it('a running lane extends to the window end with a null duration', () => {
+ const codeDispatches = new Map([['p1', [
+ {
+ kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
+ call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
+ content: [], isError: false, callView: null, resultView: null,
+ },
+ { callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
+ ]]]) as unknown as ConversationSnapshot['codeDispatches']
+ const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
+ const running = lanes.get(3)?.find((lane) => lane.name === 'grep')
+ expect(running).toMatchObject({ durationMs: null, timing: 'running' })
+ // Extends from its start to the window end.
+ expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
+ })
+
+ it('a settle-only entry (null callTime) is unknown timing, never a measured 0 ms', () => {
+ const codeDispatches = new Map([['p1', [
+ {
+ kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
+ call: { name: 'bash', argsRaw: '{}' }, callTime: null,
+ content: [], isError: false, callView: null, resultView: null,
+ },
+ ]]]) as unknown as ConversationSnapshot['codeDispatches']
+ const lane = deriveSubSpans(dispatchNodes, codeDispatches).get(3)?.[0]
+ expect(lane).toMatchObject({ durationMs: null, timing: 'unknown' })
+ })
+
+ it('waterfall renders sub-span lanes under the owning turn row', () => {
+ const codeDispatches = new Map([['p1', [
+ {
+ kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
+ call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
+ content: [], isError: false, callView: null, resultView: null,
+ },
+ ]]]) as unknown as ConversationSnapshot['codeDispatches']
+ const store = createSnapshotStore({
+ nodes: dispatchNodes, partial: null,
+ runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
+ })
+ const props = {
+ sessionId: SID,
+ useSession: bindSnapshotSelector(store) as unknown as UseSession,
+ useSessions: emptySessions(),
+ useWorkspaces: emptyWorkspaces(),
+ } as unknown as ConvViewProps
+ const view = render(createElement(WaterfallView as FC, props))
+ const lane = view.container.querySelector('[data-subspan]')
+ expect(lane).not.toBeNull()
+ expect(lane!.textContent).toContain('bash')
+ expect(lane!.querySelector('[title*="1.80s"]')).not.toBeNull()
+ expect(lane!.querySelector('[data-timing="measured"]')).not.toBeNull()
+ })
+
+ it('waterfall labels a settle-only lane as duration unknown', () => {
+ const codeDispatches = new Map([['p1', [
+ {
+ kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
+ call: { name: 'read', argsRaw: '{}' }, callTime: null,
+ content: [], isError: false, callView: null, resultView: null,
+ },
+ ]]]) as unknown as ConversationSnapshot['codeDispatches']
+ const store = createSnapshotStore({
+ nodes: dispatchNodes, partial: null,
+ runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
+ })
+ const props = {
+ sessionId: SID,
+ useSession: bindSnapshotSelector(store) as unknown as UseSession,
+ useSessions: emptySessions(),
+ useWorkspaces: emptyWorkspaces(),
+ } as unknown as ConvViewProps
+ const view = render(createElement(WaterfallView as FC, props))
+ const bar = view.container.querySelector('[data-timing="unknown"]')
+ expect(bar).not.toBeNull()
+ expect(bar!.getAttribute('title')).toContain('duration unknown')
+ })
+})
diff --git a/packages/client/web/src/base.css b/packages/client/web/src/base.css
index 991a03bbca..b8449634eb 100644
--- a/packages/client/web/src/base.css
+++ b/packages/client/web/src/base.css
@@ -1,9 +1,10 @@
/* Shell-owned global base: full-height mount plus the theme token sheets.
- * The three ui-theme sheets are the sole token source (--dsw-*); the shell
+ * The four ui-theme sheets are the sole token source (--dsw-*); the shell
* links them here so tokens exist before any plugin CSS lands. */
@import '@deepseek-ai/dsh-client-ui-theme/styles/base.css';
@import '@deepseek-ai/dsh-client-ui-theme/styles/design-platform.css';
@import '@deepseek-ai/dsh-client-ui-theme/styles/gradient-shadow-text.css';
+@import '@deepseek-ai/dsh-client-ui-theme/styles/shiki.css';
html,
body,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fc4b87c934..e5f104e115 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -930,6 +930,9 @@ importers:
packages/client/ui-primitives:
dependencies:
+ '@shikijs/langs':
+ specifier: ^4.3.1
+ version: 4.3.1
clsx:
specifier: ^2.0.0
version: 2.1.1
@@ -945,6 +948,9 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
+ shiki:
+ specifier: ^4.3.1
+ version: 4.3.1
devDependencies:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
@@ -6770,24 +6776,52 @@ packages:
'@shikijs/core@2.5.0':
resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==}
+ '@shikijs/core@4.3.1':
+ resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
+ engines: {node: '>=20'}
+
'@shikijs/engine-javascript@2.5.0':
resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==}
+ '@shikijs/engine-javascript@4.3.1':
+ resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
+ engines: {node: '>=20'}
+
'@shikijs/engine-oniguruma@2.5.0':
resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==}
+ '@shikijs/engine-oniguruma@4.3.1':
+ resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
+ engines: {node: '>=20'}
+
'@shikijs/langs@2.5.0':
resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==}
+ '@shikijs/langs@4.3.1':
+ resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
+ engines: {node: '>=20'}
+
+ '@shikijs/primitive@4.3.1':
+ resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
+ engines: {node: '>=20'}
+
'@shikijs/themes@2.5.0':
resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==}
+ '@shikijs/themes@4.3.1':
+ resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
+ engines: {node: '>=20'}
+
'@shikijs/transformers@2.5.0':
resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==}
'@shikijs/types@2.5.0':
resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==}
+ '@shikijs/types@4.3.1':
+ resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
+ engines: {node: '>=20'}
+
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -8931,9 +8965,15 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+ oniguruma-parser@0.12.2:
+ resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
+
oniguruma-to-es@3.1.1:
resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==}
+ oniguruma-to-es@4.3.6:
+ resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
+
openai@6.26.0:
resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==}
hasBin: true
@@ -9290,6 +9330,10 @@ packages:
shiki@2.5.0:
resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==}
+ shiki@4.3.1:
+ resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
+ engines: {node: '>=20'}
+
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
@@ -11407,25 +11451,58 @@ snapshots:
'@types/hast': 3.0.5
hast-util-to-html: 9.0.5
+ '@shikijs/core@4.3.1':
+ dependencies:
+ '@shikijs/primitive': 4.3.1
+ '@shikijs/types': 4.3.1
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.5
+ hast-util-to-html: 9.0.5
+
'@shikijs/engine-javascript@2.5.0':
dependencies:
'@shikijs/types': 2.5.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 3.1.1
+ '@shikijs/engine-javascript@4.3.1':
+ dependencies:
+ '@shikijs/types': 4.3.1
+ '@shikijs/vscode-textmate': 10.0.2
+ oniguruma-to-es: 4.3.6
+
'@shikijs/engine-oniguruma@2.5.0':
dependencies:
'@shikijs/types': 2.5.0
'@shikijs/vscode-textmate': 10.0.2
+ '@shikijs/engine-oniguruma@4.3.1':
+ dependencies:
+ '@shikijs/types': 4.3.1
+ '@shikijs/vscode-textmate': 10.0.2
+
'@shikijs/langs@2.5.0':
dependencies:
'@shikijs/types': 2.5.0
+ '@shikijs/langs@4.3.1':
+ dependencies:
+ '@shikijs/types': 4.3.1
+
+ '@shikijs/primitive@4.3.1':
+ dependencies:
+ '@shikijs/types': 4.3.1
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.5
+
'@shikijs/themes@2.5.0':
dependencies:
'@shikijs/types': 2.5.0
+ '@shikijs/themes@4.3.1':
+ dependencies:
+ '@shikijs/types': 4.3.1
+
'@shikijs/transformers@2.5.0':
dependencies:
'@shikijs/core': 2.5.0
@@ -11436,6 +11513,11 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
+ '@shikijs/types@4.3.1':
+ dependencies:
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.5
+
'@shikijs/vscode-textmate@10.0.2': {}
'@smithy/core@3.24.7':
@@ -13998,12 +14080,20 @@ snapshots:
dependencies:
wrappy: 1.0.2
+ oniguruma-parser@0.12.2: {}
+
oniguruma-to-es@3.1.1:
dependencies:
emoji-regex-xs: 1.0.0
regex: 6.1.0
regex-recursion: 6.0.2
+ oniguruma-to-es@4.3.6:
+ dependencies:
+ oniguruma-parser: 0.12.2
+ regex: 6.1.0
+ regex-recursion: 6.0.2
+
openai@6.26.0(ws@8.21.0)(zod@4.4.3):
optionalDependencies:
ws: 8.21.0
@@ -14504,6 +14594,17 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
+ shiki@4.3.1:
+ dependencies:
+ '@shikijs/core': 4.3.1
+ '@shikijs/engine-javascript': 4.3.1
+ '@shikijs/engine-oniguruma': 4.3.1
+ '@shikijs/langs': 4.3.1
+ '@shikijs/themes': 4.3.1
+ '@shikijs/types': 4.3.1
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.5
+
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0