Browse Source

feat(web): Code Mode sub-calls in the trajectory and waterfall views

Trajectory: the layout fold interleaves one subtool cell per sub-dispatch
after its parent Tool cell (assistant-block calls, orphan results, and
running calls alike), indexes sequential across the interleave; settled
durations come from the start/settle pair, running sub-calls show the
em dash. New Sub tag (business tint) + 28px indent.

Waterfall: deriveSubSpans folds the dispatch index into per-turn lanes
with REAL wall time — each parent's window is first start → last settle
and every lane's offset/width is its fraction of it, so parallel
sub-calls visibly overlap; running lanes extend to the window end at
reduced opacity. Lanes draw under the owning turn row.

Both views read codeDispatches through the standard snapshot hook; no
new wire data, replay renders identically to live. Specs pin interleave
order, durations, the running arms, window fractions, and the rendered
lane.
Tianyi Cui 2 tháng trước cách đây
mục cha
commit
e715d6cc59

+ 6 - 0
.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: 54449bcf8612a39461a769173d7f60c742f67ad8
+2026-07-26-code-mode-trajectory-waterfall-spans.zh.md: fbfb26c3a62554d60a6cb561ead78e10cd4115cd

+ 31 - 0
.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. Running lanes extend to the window end at reduced opacity with a null duration. 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, and the rendered lane under the turn row.

+ 31 - 0
.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)会肉眼可见地重叠。运行中的泳道以较低的不透明度延伸至窗口末端,耗时为 null。泳道绘制在所属轮次的条形行之下,并缩放进固定的泳道预算。
+- 两个视图都经由标准的快照 hook 读取 `codeDispatches`:没有新的 wire 数据,也没有新的 store;回放的渲染由构造保证与实时完全一致。
+
+## 曾考虑的替代方案
+
+**把子调用折入轮次 span 的节点计数(给既有的条加权)。** 否决:它隐藏的恰恰是本堆叠 PR 链存在就是为了展示的结构,而且节点计数加权本就已被标记为占位(偏差账本 #3)。
+
+**用专用的子调用面板取代视图内嵌套。** 否决:本堆叠 PR 链已敲定的 UX 是处处嵌套在父级之下;独立面板会与 chat 发生偏差,还会让选中接线翻倍。
+
+**把 waterfall 泳道推迟到 P-III 的时长泳道重新设计。** 否决:子泳道的计时如今已是真实的(即那对事件),而按窗口占比的渲染与轮次级泳道将来的形态无关;推迟只会让本堆叠 PR 链的计时收益搁浅。
+
+## 后果
+
+waterfall 承载了 client 中第一处真实的墙钟时间渲染(轮次条仍是节点计数的占位;这一反差是有意为之,并由悬停标题标注)。trajectory 的单元格索引现在会把子调用计入,因此 Code Mode 轮次上的 `#N` 总数会随之增大。spec 锁定穿插顺序与耗时、运行中的破折号分支、窗口占比(偏移/宽度)、运行中泳道的延伸,以及轮次行之下实际渲染出的泳道。

+ 11 - 0
packages/client/ui-trajectory/src/client/TrajectoryCell.module.css

@@ -61,6 +61,17 @@
   background: var(--dsw-alias-state-warn-tertiary);
   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 {
 .text {
   flex: 1 1 auto;
   flex: 1 1 auto;
   min-width: 0;
   min-width: 0;

+ 5 - 2
packages/client/ui-trajectory/src/client/TrajectoryCell.tsx

@@ -4,20 +4,23 @@
 import type { HTMLAttributes } from 'react'
 import type { HTMLAttributes } from 'react'
 import css from './TrajectoryCell.module.css'
 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). */
 /** Display label per kind (matches the design tags). */
 const KIND_LABEL: Record<TrajectoryCellKind, string> = {
 const KIND_LABEL: Record<TrajectoryCellKind, string> = {
   user: 'User',
   user: 'User',
   message: 'Message',
   message: 'Message',
   tool: 'Tool',
   tool: 'Tool',
+  subtool: 'Sub',
 }
 }
 
 
 const TAG_CLASS: Record<TrajectoryCellKind, string> = {
 const TAG_CLASS: Record<TrajectoryCellKind, string> = {
   user: css.tagUser!,
   user: css.tagUser!,
   message: css.tagMessage!,
   message: css.tagMessage!,
   tool: css.tagTool!,
   tool: css.tagTool!,
+  subtool: css.tagSubtool!,
 }
 }
 
 
 export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
 export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {

+ 3 - 2
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -12,9 +12,10 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
   const nodes = useSession((s) => s.nodes)
   const nodes = useSession((s) => s.nodes)
   const partial = useSession((s) => s.partial)
   const partial = useSession((s) => s.partial)
   const runningCalls = useSession((s) => s.runningCalls)
   const runningCalls = useSession((s) => s.runningCalls)
+  const codeDispatches = useSession((s) => s.codeDispatches)
   const turns = useMemo(
   const turns = useMemo(
-    () => deriveTrajectoryLayout({ nodes, partial, runningCalls }),
-    [nodes, partial, runningCalls],
+    () => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
+    [nodes, partial, runningCalls, codeDispatches],
   )
   )
   if (turns.length === 0) {
   if (turns.length === 0) {
     return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
     return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>

+ 38 - 16
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 { useMemo } from 'react'
 import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 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 { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
 import css from './views.module.css'
 import css from './views.module.css'
 
 
 /** Bar width scale: px per node, clamped so tiny windows still show a bar. */
 /** Bar width scale: px per node, clamped so tiny windows still show a bar. */
 const PX_PER_NODE = 14
 const PX_PER_NODE = 14
 const MIN_BAR_PX = 8
 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). */
 /** Optional density override (test/standalone knob; the register site passes nothing). */
 export interface WaterfallExtraProps {
 export interface WaterfallExtraProps {
@@ -21,27 +25,45 @@ export interface WaterfallExtraProps {
 export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
 export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
   const scale = pxPerNode ?? PX_PER_NODE
   const scale = pxPerNode ?? PX_PER_NODE
   const nodes = useSession((s) => s.nodes)
   const nodes = useSession((s) => s.nodes)
+  const codeDispatches = useSession((s) => s.codeDispatches)
   const spans = useMemo(() => deriveSpans(nodes), [nodes])
   const spans = useMemo(() => deriveSpans(nodes), [nodes])
+  const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
   if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无瀑布数据</p></div>
   if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无瀑布数据</p></div>
   return (
   return (
     <>
     <>
       <TrajectoryStatsHeader useSession={useSession} />
       <TrajectoryStatsHeader useSession={useSession} />
       <div className={css.root}>
       <div className={css.root}>
         {spans.map((span, i) => (
         {spans.map((span, i) => (
-          <div key={span.turn} className={css.row} style={{ paddingLeft: i * 12 }}>
-            <span className={css.turnTag}>turn {span.turn}</span>
-            <span
-              className={css.bar}
-              style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
-              title={`${span.nodes} nodes`}
-            />
-            {span.calls > 0 && (
+          <div key={span.turn}>
+            <div className={css.row} style={{ paddingLeft: i * 12 }}>
+              <span className={css.turnTag}>turn {span.turn}</span>
               <span
               <span
-                className={`${css.bar} ${css.barCalls}`}
-                style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
-                title={`${span.calls} tool calls`}
+                className={css.bar}
+                style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
+                title={`${span.nodes} nodes`}
               />
               />
-            )}
+              {span.calls > 0 && (
+                <span
+                  className={`${css.bar} ${css.barCalls}`}
+                  style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
+                  title={`${span.calls} tool calls`}
+                />
+              )}
+            </div>
+            {(subSpans.get(span.turn) ?? []).map((lane) => (
+              <div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
+                <span className={css.subTag}>{lane.name}</span>
+                <span
+                  className={`${css.bar} ${css.barSub}`}
+                  data-running={lane.durationMs === null || undefined}
+                  style={{
+                    marginLeft: Math.round(lane.offsetFraction * SUB_LANE_PX),
+                    width: Math.max(Math.round(lane.widthFraction * SUB_LANE_PX), 4),
+                  }}
+                  title={lane.durationMs === null ? `${lane.name} · running` : `${lane.name} · ${(lane.durationMs / 1000).toFixed(2)}s`}
+                />
+              </div>
+            ))}
           </div>
           </div>
         ))}
         ))}
       </div>
       </div>

+ 60 - 2
packages/client/ui-trajectory/src/client/layout.ts

@@ -4,6 +4,7 @@
  */
  */
 import type {
 import type {
   AssistantMessageNode,
   AssistantMessageNode,
+  CodeSubCall,
   ConversationSnapshot,
   ConversationSnapshot,
   ToolResultNode,
   ToolResultNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
 } from '@deepseek-ai/dsh-client-runtime/client'
@@ -27,6 +28,8 @@ export interface TrajectoryLayoutInput {
   nodes: ConversationSnapshot['nodes']
   nodes: ConversationSnapshot['nodes']
   partial: ConversationSnapshot['partial']
   partial: ConversationSnapshot['partial']
   runningCalls: ConversationSnapshot['runningCalls']
   runningCalls: ConversationSnapshot['runningCalls']
+  /** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
+  codeDispatches: ConversationSnapshot['codeDispatches']
 }
 }
 
 
 interface UsageLike {
 interface UsageLike {
@@ -49,7 +52,7 @@ interface LaidCell {
  * @returns turns ordered by first appearance.
  * @returns turns ordered by first appearance.
  */
  */
 export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
 export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
-  const { nodes, partial, runningCalls } = input
+  const { nodes, partial, runningCalls, codeDispatches } = input
   const resultByCall = indexResults(nodes)
   const resultByCall = indexResults(nodes)
   const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
   const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
   let index = 0
   let index = 0
@@ -96,7 +99,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
       continue
       continue
     }
     }
     if (node.kind === 'assistant') {
     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) {
       for (const laid of laidList) {
         if (node.step > 0) pushStep(node.turn, node.step, laid)
         if (node.step > 0) pushStep(node.turn, node.step, laid)
         else pushMessage(node.turn, laid)
         else pushMessage(node.turn, laid)
@@ -128,6 +131,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
             timeSeconds: durationSeconds(node.time, node.callTime),
             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
       prevAbsTime = finiteTime(node.time) ?? prevAbsTime
     }
     }
@@ -161,6 +168,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
         timeSeconds: null,
         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.
   // Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
@@ -387,6 +398,53 @@ function collectCallIds(
   return ids
   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 {
 function summarizeCall(name: string, argsRaw: string): string {
   const args = argsRaw.replace(/\s+/g, ' ').trim()
   const args = argsRaw.replace(/\s+/g, ' ').trim()
   if (args === '') return name
   if (args === '') return name

+ 65 - 0
packages/client/ui-trajectory/src/client/spans.ts

@@ -5,6 +5,18 @@
  */
  */
 import type { ConversationNode, ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
 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 while running (start seen, settle not). */
+  durationMs: number | null
+  /** 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. */
 /** One turn's worth of activity, folded from the snapshot node window. */
 export interface TurnSpan {
 export interface TurnSpan {
   turn: number
   turn: number
@@ -69,3 +81,56 @@ export function deriveSpanStats(spans: readonly TurnSpan[]): SpanStats {
 function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } {
 function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } {
   return node.kind === 'assistant' || node.kind === 'steering'
   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<number, readonly SubSpanLane[]> {
+  const out = new Map<number, SubSpanLane[]>()
+  if (codeDispatches.size === 0) return out
+  const turnByCall = new Map<string, number>()
+  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
+    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 start = starts[i] ?? windowStart
+      const end = settled ? sub.time : windowEnd
+      return {
+        callId: sub.callId,
+        name: settled ? sub.call?.name ?? sub.callId : sub.name,
+        durationMs: settled ? Math.max(0, sub.time - start) : null,
+        offsetFraction: (start - windowStart) / windowSpan,
+        widthFraction: Math.max((end - start) / windowSpan, 0.02),
+      }
+    })
+    const existing = out.get(turn) ?? []
+    out.set(turn, [...existing, ...lanes])
+  }
+  return out
+}

+ 29 - 0
packages/client/ui-trajectory/src/client/views.module.css

@@ -45,3 +45,32 @@
   color: var(--dsw-alias-label-caption);
   color: var(--dsw-alias-label-caption);
   font: var(--dsw-font-xs-13);
   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-running] {
+  opacity: 0.45;
+}

+ 55 - 6
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,
         content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
       },
       },
     ] as unknown as ConversationSnapshot['nodes']
     ] 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).toHaveLength(1)
     expect(turns[0]?.turn).toBe(1)
     expect(turns[0]?.turn).toBe(1)
     const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
     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', () => {
   it('adds runningCalls not already present and leaves their time blank', () => {
     const turns = deriveTrajectoryLayout({
     const turns = deriveTrajectoryLayout({
+      codeDispatches: new Map(),
       nodes: [] as unknown as ConversationSnapshot['nodes'],
       nodes: [] as unknown as ConversationSnapshot['nodes'],
       partial: null,
       partial: null,
       runningCalls: [{
       runningCalls: [{
@@ -111,7 +112,7 @@ describe('deriveTrajectoryLayout', () => {
         usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
         usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
       },
       },
     ] as unknown as ConversationSnapshot['nodes']
     ] 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) ?? []
     const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
     expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
     expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
     expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
     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,
         content: [], isError: false, callView: null, resultView: null,
       },
       },
     ] as unknown as ConversationSnapshot['nodes']
     ] 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')
     expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
   })
   })
 
 
@@ -154,7 +155,7 @@ describe('deriveTrajectoryLayout', () => {
         blocks: [{ kind: 'text', text: 'ok2' }],
         blocks: [{ kind: 'text', text: 'ok2' }],
       },
       },
     ] as unknown as ConversationSnapshot['nodes']
     ] 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.map((t) => t.turn)).toEqual([1, 2])
     expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
     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'])
     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 },
         usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
       },
       },
     ] as unknown as ConversationSnapshot['nodes']
     ] 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')
     const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
     expect(message).toMatchObject({
     expect(message).toMatchObject({
       text: '', input: 11, output: 22, think: 3,
       text: '', input: 11, output: 22, think: 3,
@@ -196,7 +197,7 @@ describe('deriveTrajectoryLayout', () => {
         blocks: [{ kind: 'text', text: 'done' }],
         blocks: [{ kind: 'text', text: 'done' }],
       },
       },
     ] as unknown as ConversationSnapshot['nodes']
     ] 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
     const message = turns[0]?.groups
       .flatMap((g) => g.cells)
       .flatMap((g) => g.cells)
       .find((c) => c.kind === 'message' && c.text === 'done')
       .find((c) => c.kind === 'message' && c.text === 'done')
@@ -204,3 +205,51 @@ describe('deriveTrajectoryLayout', () => {
     expect(message?.timeSeconds).toBe(1)
     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 })
+  })
+})

+ 78 - 3
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 { 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 { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
 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 { 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 { 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'
 import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
@@ -54,7 +54,7 @@ const NODES = [
 
 
 function fakeSession(nodes: ConversationSnapshot['nodes']) {
 function fakeSession(nodes: ConversationSnapshot['nodes']) {
   const store = createSnapshotStore({
   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<ConversationSnapshot> }
   return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
 }
 }
@@ -117,7 +117,7 @@ function tabsOf(slots: SlotsService): ViewTab[] {
 function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
 function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
   const sessionSnapshot = createSnapshotStore({
   const sessionSnapshot = createSnapshotStore({
     running: false, removed: false, promptError: null, nodes,
     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<ConversationSnapshot>
   const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
   const chat = createChatStore().create()
   const chat = createChatStore().create()
@@ -260,3 +260,78 @@ describe('node half', () => {
     expect(nodeApply()).toBeUndefined()
     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, 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 })
+    // Extends from its start to the window end.
+    expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
+  })
+
+  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<ConversationSnapshot>,
+      useSessions: emptySessions(),
+      useWorkspaces: emptyWorkspaces(),
+    } as unknown as ConvViewProps
+    const view = render(createElement(WaterfallView as FC<ConvViewProps>, 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()
+  })
+})