فهرست منبع

feat(trajectory): implement trajectory step cell and layout with bilingual support

- Added TrajectoryCell, TrajectoryGroupHeader, and TrajectoryTurn components for rendering trajectory steps and groups.
- Introduced bilingual support with English and Chinese translations for trajectory notes.
- Updated conversation session models to include timestamps for various message types.
- Enhanced layout logic to handle expanded assistant blocks and tool results with duration metrics.
- Added CSS styles for new components to ensure proper display and alignment.
07akioni 1 ماه پیش
والد
کامیت
3babb2cd42
29فایلهای تغییر یافته به همراه1190 افزوده شده و 70 حذف شده
  1. 6 0
      .agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml
  2. 35 0
      .agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md
  3. 35 0
      .agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md
  4. 16 0
      packages/client/runtime/src/client/sessions/conversation.ts
  5. 22 6
      packages/client/runtime/src/client/sessions/fold-adapter.ts
  6. 6 3
      packages/client/runtime/src/client/sessions/session.ts
  7. 4 3
      packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
  8. 3 2
      packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
  9. 5 4
      packages/client/ui-conversation/tests/chat-view.spec.tsx
  10. 4 2
      packages/client/ui-conversation/tests/coverage-tails.spec.tsx
  11. 2 2
      packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
  12. 5 4
      packages/client/ui-conversation/tests/skeleton.spec.tsx
  13. 4 1
      packages/client/ui-theme/src/styles/design-platform.css
  14. 2 2
      packages/client/ui-trajectory/README.md
  15. 93 0
      packages/client/ui-trajectory/src/client/TrajectoryCell.module.css
  16. 99 0
      packages/client/ui-trajectory/src/client/TrajectoryCell.tsx
  17. 27 0
      packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css
  18. 26 0
      packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx
  19. 16 0
      packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css
  20. 26 0
      packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx
  21. 48 0
      packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css
  22. 30 0
      packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx
  23. 30 20
      packages/client/ui-trajectory/src/client/TrajectoryView.tsx
  24. 2 2
      packages/client/ui-trajectory/src/client/index.ts
  25. 374 0
      packages/client/ui-trajectory/src/client/layout.ts
  26. 14 4
      packages/client/ui-trajectory/src/client/views.module.css
  27. 87 0
      packages/client/ui-trajectory/tests/cell.spec.tsx
  28. 143 0
      packages/client/ui-trajectory/tests/layout.spec.tsx
  29. 26 15
      packages/client/ui-trajectory/tests/views.spec.tsx

+ 6 - 0
.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.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-23-trajectory-step-cell.md: edf31dcc72baa980caf0aa90fb8d5ec53d44346d
+2026-07-23-trajectory-step-cell.zh.md: dbe813d48c3f3ac1c0926e45137624d758109c55

+ 35 - 0
.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md

@@ -0,0 +1,35 @@
+# Agent Note: Trajectory step cell and turn list chrome
+
+Status: implemented
+
+English | [中文](2026-07-23-trajectory-step-cell.zh.md)
+
+## Problem
+
+The trajectory tab needs a reusable step row and turn-list chrome that can show expanded assistant blocks, own-duration times, Message token columns, and in-flight work. Without folding session event times into conversation nodes and expanding blocks into cells, the UI cannot match the product chrome.
+
+## Decision
+
+[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) owns the presentational trajectory list chrome:
+
+- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 38px step row with kinds User / Message / Tool (no Think, Call, or Result rows). Reasoning blocks are skipped (no block-level clock). Each `tool-call` + paired `tool-result` folds into one Tool row (`name ·` truncated args) whose Time is `result.time − callTime` when both are known. Message rows carry Input/Output/Think token columns from `assistant.usage`. Own-duration Time uses `+Ns` / `+N.1s`, or `—` when absent. Selected state draws a 2px inset `--dsw-alias-brand-primary-new-colorprimary-new-color` ring (`selected` prop) and is not wired to chat selection.
+- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — sticky Turn bar paints full-bleed `ghost-active-fill`; title/columns and the Message/Step body sit in a centered `max-width: 880px` lane. Cell trailing columns share the Turn header geometry (`320 = 4×71 + 3×12`); cells use pad 20/8.
+- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only, and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`).
+
+[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time; Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration).
+
+## Alternatives considered
+
+**Keep a Think cell for reasoning blocks.** Rejected: a single `assistant/message.time` cannot yield Think own-duration without chunk-level clocks; omit the row rather than show `—`.
+
+**Keep separate Call and Result rows.** Rejected: Result had no own duration to show; one Tool row carries the call→result interval.
+
+**Cumulative elapsed from session/turn start.** Rejected; the Time column is each row's own duration.
+
+**Hang usage on the first expanded row.** Rejected; usage attaches to Message only.
+
+**Show in-flight tool durations via Date.now().** Deferred; in-flight Time stays `—`.
+
+## Consequences
+
+The Trajectory tab can render expanded finalized and in-flight rows with own-duration times once fold emits `time`. Behavior-shaped coverage lives in `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`. Chat selection deep-links and finer block-level clocks remain deferred.

+ 35 - 0
.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: Trajectory 步骤单元格与轮次列表 chrome
+
+Status: implemented
+
+[English](2026-07-23-trajectory-step-cell.md) | 中文
+
+## Problem
+
+trajectory 标签页需要可复用的步骤行与轮次列表 chrome,以展示展开后的 assistant 块、自身耗时、Message token 列,以及进行中的工作。若不将会话事件时间折叠进会话节点,并将块展开为单元格,UI 就无法对齐产品 chrome。
+
+## Decision
+
+[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) 拥有展示型 trajectory 列表 chrome:
+
+- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 高 38px 的步骤行,类型为 User / Message / Tool(无 Think、Call、Result 行)。reasoning 块跳过(无块级时钟)。每对 `tool-call` + `tool-result` 折成一行 Tool(`name ·` 加截断参数),Time 在两端皆知时为 `result.time − callTime`。Message 行携带来自 `assistant.usage` 的 Input/Output/Think token 列。自身耗时 Time 使用 `+Ns` / `+N.1s`,缺失时为 `—`。选中态绘制 2px 内嵌的 `--dsw-alias-brand-primary-new-colorprimary-new-color` 环(`selected` prop),且未接线到 chat 选中。
+- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — 粘性 Turn 条背景通栏铺 `ghost-active-fill`;标题/列标与 Message/Step 主体落在居中的 `max-width: 880px` 内容道。单元格右侧列与 Turn 标头共用几何(`320 = 4×71 + 3×12`);cell pad 20/8。
+- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上,并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。
+
+[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间;Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。
+
+## Alternatives considered
+
+**为 reasoning 块保留 Think 单元格。** 否决:单条 `assistant/message.time` 无法给出 Think 自身耗时(除非上 chunk 级时钟);与其显示 `—`,不如省略该行。
+
+**保留分开的 Call 与 Result 行。** 否决:Result 没有可展示的自身耗时;一行 Tool 承载 call→result 区间。
+
+**自会话/轮次起点累计耗时。** 否决;Time 列是每行自身的耗时。
+
+**将用量挂在展开后的第一行。** 否决;用量仅附着于 Message。
+
+**用 Date.now() 显示进行中工具的耗时。** 延后;进行中的 Time 保持为 `—`。
+
+## Consequences
+
+一旦 fold 发出 `time`,Trajectory 标签页即可渲染带自身耗时的已定稿与进行中展开行。行为导向的覆盖位于 `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`。chat 选中深链与更细的块级时钟仍延后。

+ 16 - 0
packages/client/runtime/src/client/sessions/conversation.ts

@@ -42,6 +42,8 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
 export interface UserMessageNode {
   kind: 'user'
   seq: number
+  /** Unix epoch ms from the source session event. */
+  time: number
   content: readonly ContentBlock[]
   source: unknown
 }
@@ -50,6 +52,8 @@ export interface UserMessageNode {
 export interface AssistantMessageNode {
   kind: 'assistant'
   seq: number
+  /** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */
+  time: number
   turn: number
   step: number
   blocks: readonly AssistantBlock[]
@@ -63,6 +67,8 @@ export interface AssistantMessageNode {
 export interface SteeringMessageNode {
   kind: 'steering'
   seq: number
+  /** Unix epoch ms from the source session event. */
+  time: number
   turn: number
   content: readonly ContentBlock[]
   source: unknown
@@ -72,6 +78,8 @@ export interface SteeringMessageNode {
 export interface ContextMessageNode {
   kind: 'context'
   seq: number
+  /** Unix epoch ms from the source session event. */
+  time: number
   content: readonly ContentBlock[]
   source: unknown
   meta?: unknown
@@ -81,9 +89,13 @@ export interface ContextMessageNode {
 export interface ToolResultNode {
   kind: 'tool-result'
   seq: number
+  /** Unix epoch ms from the tool/result session event. */
+  time: number
   callId: string
   /** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
   call: { name: string; argsRaw: string } | null
+  /** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */
+  callTime: number | null
   content: readonly ContentBlock[]
   isError: boolean
   error?: { name: string; code: string }
@@ -98,6 +110,8 @@ export interface ToolResultNode {
 export interface UnknownSurfaceNode {
   kind: 'unknown'
   seq: number
+  /** Unix epoch ms from the source session event when known. */
+  time: number
   type: string
   data: unknown
 }
@@ -118,6 +132,8 @@ export interface RunningToolCall {
   argsRaw: string
   turn: number
   step: number
+  /** Unix epoch ms when the tool/call event was logged. */
+  time: number
   /** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
   callView: ToolCallView | null
 }

+ 22 - 6
packages/client/runtime/src/client/sessions/fold-adapter.ts

@@ -18,6 +18,8 @@ export interface CallIndexEntry {
   argsRaw: string
   turn: number
   step: number
+  /** Unix epoch ms of the tool/call event. */
+  time: number
   /** Wire view riding the tool/call (envelope-level; never inside the event). */
   callView: ToolCallView | null
 }
@@ -38,24 +40,34 @@ function materializeNode(
 ): ConversationNode {
   switch (event.type) {
     case 'user/message':
-      return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }
+      return {
+        kind: 'user', seq: event.seq, time: event.time,
+        content: event.data.content, source: event.data.source,
+      }
     case 'assistant/message':
       return {
-        kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step,
+        kind: 'assistant', seq: event.seq, time: event.time,
+        turn: event.data.turn, step: event.data.step,
         blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
       }
     case 'steering/message':
-      return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source }
+      return {
+        kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
+        content: event.data.content, source: event.data.source,
+      }
     case 'context/message':
       return {
-        kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
+        kind: 'context', seq: event.seq, time: event.time,
+        content: event.data.content, source: event.data.source,
         meta: event.data.meta,
       }
     case 'tool/result': {
       const call = callIndex.get(String(event.data.callId))
       return {
-        kind: 'tool-result', seq: event.seq, callId: String(event.data.callId),
+        kind: 'tool-result', seq: event.seq, time: event.time,
+        callId: String(event.data.callId),
         call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
+        callTime: call?.time ?? null,
         content: event.data.content, isError: event.data.isError,
         ...(event.data.error !== undefined ? { error: event.data.error } : {}),
         meta: event.data.meta,
@@ -67,7 +79,10 @@ function materializeNode(
     surface-eligible types, and each has a case above; reachable only if core
     adds an eligible type. */
     default:
-      return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data }
+      return {
+        kind: 'unknown', seq: event.seq, time: event.time,
+        type: event.type, data: (event as { data?: unknown }).data,
+      }
   }
 }
 
@@ -186,6 +201,7 @@ export class FoldAdapter {
     if (event.type !== 'tool/call') return
     this.callIdx.set(String(event.data.callId), {
       name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
+      time: event.time,
       callView: view?.for === 'call' ? view.view : null,
     })
     // No backfill into already-materialized tool-result nodes for this callId

+ 6 - 3
packages/client/runtime/src/client/sessions/session.ts

@@ -434,7 +434,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
       case 'tool/call': {
         this.openCalls.set(String(event.data.callId), {
           callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
-          turn: event.data.turn, step: event.data.step,
+          turn: event.data.turn, step: event.data.step, time: event.time,
           callView: view?.for === 'call' ? view.view : null,
         })
         this.callsRev++
@@ -455,7 +455,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
           if (visible) {
             // Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
             this.frozenNodes.push({
-              kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step,
+              kind: 'assistant', seq: event.seq - 0.9, time: event.time,
+              turn: this.partial.turn, step: this.partial.step,
               blocks, interrupted: true,
             })
             this.frozenRev++
@@ -469,8 +470,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
           this.callsRev++
           // The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
           this.frozenNodes.push({
-            kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId,
+            kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
+            callId,
             call: { name: call.name, argsRaw: call.argsRaw },
+            callTime: call.time,
             content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
             callView: call.callView, resultView: null,
           })

+ 4 - 3
packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx

@@ -20,7 +20,7 @@ afterEach(cleanup)
 const SID = 's1' as SessionId
 
 const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({
-  kind: 'assistant', seq, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }],
+  kind: 'assistant', seq, time: seq * 1_000, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }],
   ...(usage === undefined ? {} : { usage }),
 })
 
@@ -65,7 +65,7 @@ describe('deriveStats', () => {
 
   it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => {
     const tool: ToolResultNode = {
-      kind: 'tool-result', seq: 5, callId: 'c', call: null, content: [],
+      kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
       isError: false, callView: null, resultView: null,
     }
     const stats = deriveStats([tool, assistant(1, 1)])
@@ -112,8 +112,9 @@ describe('bash sample row', () => {
   const CHILD = 'child-1' as SessionId
 
   const result = (callId: string): ToolResultNode => ({
-    kind: 'tool-result', seq: 3, callId,
+    kind: 'tool-result', seq: 3, time: 3_000, callId,
     call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
+    callTime: 2_000,
     content: [], isError: false, callView: null, resultView: null,
   })
 

+ 3 - 2
packages/client/ui-conversation/tests/chat-tool-row.spec.tsx

@@ -12,12 +12,13 @@ import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/
 
 const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
   callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
-  turn: 1, step: 1, callView: null, ...over,
+  turn: 1, step: 1, time: 1_000, callView: null, ...over,
 })
 
 const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
-  kind: 'tool-result', seq: 10, callId: 'c1',
+  kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
   call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
+  callTime: 1_000,
   content: [], isError: false, callView: null, resultView: null, ...over,
 })
 

+ 5 - 4
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -54,18 +54,19 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
 }
 
 const user = (seq: number, text: string): UserMessageNode => ({
-  kind: 'user', seq, content: [{ type: 'text', text }] as never, source: null,
+  kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null,
 })
 const assistant = (seq: number, text: string): AssistantMessageNode => ({
-  kind: 'assistant', seq, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
+  kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
 })
 const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
-  kind: 'tool-result', seq, callId,
+  kind: 'tool-result', seq, time: seq * 1_000, callId,
   call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
+  callTime: seq * 1_000 - 500,
   content: [], isError: false, callView: null, resultView: null,
 })
 const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
-  callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null,
+  callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
 })
 
 /** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */

+ 4 - 2
packages/client/ui-conversation/tests/coverage-tails.spec.tsx

@@ -62,8 +62,9 @@ describe('tails', () => {
 
   it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
     const settled: ToolResultNode = {
-      kind: 'tool-result', seq: 2, callId: 'c5',
+      kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
       call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
+      callTime: 1_000,
       content: [], isError: false, callView: null, resultView: null,
     }
     const props: ToolRowOwnerProps = {
@@ -77,8 +78,9 @@ describe('tails', () => {
 
   it('BashRow shows the failed pill on error results (root session arm)', () => {
     const errorResult: ToolResultNode = {
-      kind: 'tool-result', seq: 1, callId: 'c1',
+      kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
       call: { name: 'bash', argsRaw: '{"command":"boom"}' },
+      callTime: 500,
       content: [], isError: true, callView: null, resultView: null,
     }
     // Root session (no parentId): the global arm renders, error pill visible.

+ 2 - 2
packages/client/ui-conversation/tests/skeleton-branches.spec.tsx

@@ -165,7 +165,7 @@ describe('DetailsPanel branches', () => {
 
   it('shows non-JSON args verbatim (streaming fragment path)', () => {
     const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, {
-      runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, callView: null }],
+      runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, time: 1_000, callView: null }],
     })
     expect(view.getByText('{"cmd": tru')).toBeTruthy()
   })
@@ -176,7 +176,7 @@ describe('DetailsPanel branches', () => {
   })
 
   it('snapshot updates re-run the material selector through the shallow equality arm', () => {
-    let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, callView: null }] } as ConversationSnapshot
+    let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, time: 1_000, callView: null }] } as ConversationSnapshot
     const subs = new Set<() => void>()
     const source = {
       getSnapshot: () => snap,

+ 5 - 4
packages/client/ui-conversation/tests/skeleton.spec.tsx

@@ -234,10 +234,11 @@ describe('DetailsPanel', () => {
   it('renders the selected call args and result off the shared store; close fires the injected callback', () => {
     const { closeDetails } = benchDetails({
       nodes: [{
-        kind: 'tool-result', callId: 'c1',
+        kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
         call: { name: 'bash', argsRaw: '{"cmd":"ls"}' },
+        callTime: 500,
         content: [{ type: 'text', text: 'file-a\nfile-b' }],
-        isError: false,
+        isError: false, callView: null, resultView: null,
       }],
     }, { turnSeq: 1, callId: 'c1' })
     expect(screen.getByText('bash')).toBeTruthy()
@@ -248,10 +249,10 @@ describe('DetailsPanel', () => {
   })
 
   it('shows the empty hint without a selection and the running state for open calls', () => {
-    benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, null)
+    benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null)
     expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy()
     cleanup()
-    benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, { turnSeq: 1, callId: 'c9' })
+    benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' })
     expect(screen.getByText('运行中…')).toBeTruthy()
   })
 

+ 4 - 1
packages/client/ui-theme/src/styles/design-platform.css

@@ -169,6 +169,7 @@ body {
   --dsw-alias-border-l3: rgba(0, 0, 0, 0.12);
   --dsw-alias-border-l4: rgba(0, 0, 0, 0.16);
   --dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-1000);
+  --dsw-alias-brand-primary-new-colorprimary-new-color: rgb(65, 118, 230);
   --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-1000);
   --dsw-alias-brand-text: var(--dsw-static-neutral-bluish-1000);
   --dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-700);
@@ -217,6 +218,7 @@ body {
   --dsw-alias-state-error-secondary: var(--dsw-static-red-400);
   --dsw-alias-state-success-primary: var(--dsw-static-green-500);
   --dsw-alias-state-success-secondary: var(--dsw-static-green-400);
+  --dsw-alias-state-success-tertiary: var(--dsw-static-green-100);
   --dsw-alias-state-warn-label: var(--dsw-static-amber-600);
   --dsw-alias-state-warn-primary: var(--dsw-static-amber-500);
   --dsw-alias-state-warn-secondary: var(--dsw-static-amber-400);
@@ -257,11 +259,12 @@ body[data-ds-dark-theme] {
   --dsw-alias-border-l3: rgba(255, 255, 255, 0.16);
   --dsw-alias-border-l4: rgba(255, 255, 255, 0.2);
   --dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-50);
+  --dsw-alias-brand-primary-new-colorprimary-new-color: var(--dsw-static-deepseek-450);
   --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-50);
   --dsw-alias-brand-text: var(--dsw-static-neutral-bluish-50);
   --dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-50);
   --dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-750);
-  --dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-950);
+  --dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-850);
   --dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-800);
   --dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-600);
   --dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-750);

+ 2 - 2
packages/client/ui-trajectory/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-client-ui-trajectory
 
-Trajectory/Waterfall placeholder views; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
+Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
 
 ## Model Experience
 
@@ -12,4 +12,4 @@ None; this package neither assembles nor sends a provider request.
 
 ## Known Limitations and Deferred Work
 
-- **Both views are placeholders by charter** — coarse span derivation with no visual acceptance bar; the real implementations, anchor deep-linking, and span-click selection handoff are the P-III project.
+- **In-flight Time stays blank** — `partial` / `runningCalls` rows render with `—` until a live clock policy lands; selected styling is local-only (not wired to chat details); anchor deep-linking remains deferred.

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

@@ -0,0 +1,93 @@
+/* Trajectory step cell — 38px row: index · kind tag · text · optional message
+ * metrics · elapsed time. */
+
+.root {
+  display: flex;
+  align-items: center;
+  box-sizing: border-box;
+  height: 38px;
+  padding: 0 8px 0 20px;
+  gap: 24px;
+  border-radius: 8px;
+  border: 1px solid var(--dsw-alias-border-l2);
+  background: var(--dsw-alias-bg-layer-3);
+  min-width: 0;
+}
+
+.selected {
+  border-color: transparent;
+  box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
+}
+
+.index {
+  flex: none;
+  width: 24px;
+  font: var(--dsw-font-xs-13);
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.tagSlot {
+  flex: none;
+  width: 80px;
+  display: flex;
+  align-items: center;
+  min-width: 0;
+}
+
+.tag {
+  display: inline-flex;
+  align-items: center;
+  box-sizing: border-box;
+  height: 22px;
+  max-width: 100%;
+  padding: 0 4px;
+  border-radius: 6px;
+  font: var(--dsw-font-xs-strong-13);
+  white-space: nowrap;
+}
+
+.tagUser {
+  color: var(--dsw-alias-state-success-primary);
+  background: var(--dsw-alias-state-success-tertiary);
+}
+
+.tagMessage {
+  color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
+  background: var(--dsw-specific-bubble);
+}
+
+.tagTool {
+  color: var(--dsw-alias-state-warn-label);
+  background: var(--dsw-alias-state-warn-tertiary);
+}
+
+.text {
+  flex: 1 1 auto;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font: var(--dsw-font-xs-13);
+  color: var(--dsw-alias-label-primary);
+}
+
+/* Same column geometry as TrajectoryTurnHeader: 4×71 + 3×12 = 320. */
+.trailing {
+  flex: none;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  width: 320px;
+  gap: 12px;
+  min-width: 0;
+}
+
+.metric,
+.time {
+  flex: none;
+  width: 71px;
+  text-align: left;
+  font: var(--dsw-font-xs-13);
+  color: var(--dsw-alias-label-tertiary);
+  white-space: nowrap;
+}

+ 99 - 0
packages/client/ui-trajectory/src/client/TrajectoryCell.tsx

@@ -0,0 +1,99 @@
+// TrajectoryCell: one step row in the trajectory list — index, kind tag,
+// ellipsis text, optional Message token metrics, and own-duration time.
+
+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'
+
+/** Display label per kind (matches the design tags). */
+const KIND_LABEL: Record<TrajectoryCellKind, string> = {
+  user: 'User',
+  message: 'Message',
+  tool: 'Tool',
+}
+
+const TAG_CLASS: Record<TrajectoryCellKind, string> = {
+  user: css.tagUser!,
+  message: css.tagMessage!,
+  tool: css.tagTool!,
+}
+
+export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
+  /** 1-based step index shown as `#N`. */
+  index: number
+  kind: TrajectoryCellKind
+  /** Single-line summary; CSS ellipsis when it overflows. */
+  text: string
+  /**
+   * Own duration in seconds. `null` means no duration to show (em dash) —
+   * used for in-flight tools and tools missing callTime.
+   */
+  timeSeconds: number | null
+  /** Message-only: prompt token count. */
+  input?: number
+  /** Message-only: completion token count. */
+  output?: number
+  /** Message-only: reasoning token count (usage column, not a Think cell). */
+  think?: number
+  /** Selected: 2px inset brand-primary-new-color ring (not wired to chat selection yet). */
+  selected?: boolean
+}
+
+/**
+ * Format own-duration for the trailing time column: `—` when unknown, `+Ns`
+ * or `+N.1s` otherwise.
+ * @param seconds - duration seconds, or null when absent.
+ * @returns display string.
+ */
+export function formatElapsedSeconds(seconds: number | null): string {
+  if (seconds === null || !Number.isFinite(seconds)) return '—'
+  const rounded = Math.round(seconds * 10) / 10
+  if (Number.isInteger(rounded)) return `+${rounded}s`
+  return `+${rounded.toFixed(1)}s`
+}
+
+/**
+ * Render one trajectory step cell.
+ * @param props - index, kind, text, time, and optional Message metrics.
+ * @returns the cell element.
+ */
+export function TrajectoryCell({
+  index,
+  kind,
+  text,
+  timeSeconds,
+  input,
+  output,
+  think,
+  selected = false,
+  className,
+  ...rest
+}: TrajectoryCellProps) {
+  const rootClass = [
+    css.root,
+    selected ? css.selected : undefined,
+    className,
+  ].filter((c): c is string => c !== undefined).join(' ')
+  const showMetrics = kind === 'message'
+  return (
+    <div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}>
+      <span className={css.index}>#{index}</span>
+      <span className={css.tagSlot}>
+        <span className={`${css.tag} ${TAG_CLASS[kind]}`}>{KIND_LABEL[kind]}</span>
+      </span>
+      <span className={css.text}>{text}</span>
+      <span className={css.trailing}>
+        {showMetrics ? (
+          <>
+            <span className={css.metric}>{input ?? ''}</span>
+            <span className={css.metric}>{output ?? ''}</span>
+            <span className={css.metric}>{think ?? ''}</span>
+          </>
+        ) : null}
+        <span className={css.time}>{formatElapsedSeconds(timeSeconds)}</span>
+      </span>
+    </div>
+  )
+}

+ 27 - 0
packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css

@@ -0,0 +1,27 @@
+/* Message / Step group title row inside a turn body. */
+
+.root {
+  display: flex;
+  align-items: center;
+  box-sizing: border-box;
+  height: 36px;
+  padding: 0 20px;
+  gap: 24px;
+  min-width: 0;
+}
+
+.title {
+  flex: none;
+  font: var(--dsw-font-xs-13);
+  color: var(--dsw-alias-label-primary);
+}
+
+.description {
+  flex: 1 1 auto;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font: var(--dsw-font-xs-13);
+  color: var(--dsw-alias-label-tertiary);
+}

+ 26 - 0
packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx

@@ -0,0 +1,26 @@
+// TrajectoryGroupHeader: "Message" or "Step N" row with optional description.
+
+import css from './TrajectoryGroupHeader.module.css'
+
+export interface TrajectoryGroupHeaderProps {
+  /** Group title (`Message`, `Step 1`, …). */
+  title: string
+  /** Secondary summary (`49s`, `2.2s skill`, …). */
+  description?: string
+}
+
+/**
+ * Render a Message/Step group header inside a turn body.
+ * @param props - title and optional description.
+ * @returns the group header element.
+ */
+export function TrajectoryGroupHeader({ title, description }: TrajectoryGroupHeaderProps) {
+  return (
+    <div className={css.root}>
+      <span className={css.title}>{title}</span>
+      {description !== undefined && description !== ''
+        ? <span className={css.description}>{description}</span>
+        : null}
+    </div>
+  )
+}

+ 16 - 0
packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css

@@ -0,0 +1,16 @@
+/* One turn block: sticky header + padded body with 10px item gap. */
+
+.root {
+  width: 100%;
+}
+
+.body {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  box-sizing: border-box;
+  width: 100%;
+  max-width: 880px;
+  margin: 0 auto;
+  padding: 8px 16px 22px;
+}

+ 26 - 0
packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx

@@ -0,0 +1,26 @@
+// TrajectoryTurn: sticky Turn header plus the padded Message/Step body.
+
+import type { ReactNode } from 'react'
+import { TrajectoryTurnHeader } from './TrajectoryTurnHeader.tsx'
+import css from './TrajectoryTurn.module.css'
+
+export interface TrajectoryTurnProps {
+  /** 1-based turn index for the sticky header. */
+  turn: number
+  /** Message / Step headers and TrajectoryCell rows. */
+  children?: ReactNode
+}
+
+/**
+ * Render one turn section (sticky header + body).
+ * @param props - turn index and body children.
+ * @returns the turn section element.
+ */
+export function TrajectoryTurn({ turn, children }: TrajectoryTurnProps) {
+  return (
+    <section className={css.root} data-turn={turn}>
+      <TrajectoryTurnHeader turn={turn} />
+      <div className={css.body}>{children}</div>
+    </section>
+  )
+}

+ 48 - 0
packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css

@@ -0,0 +1,48 @@
+/* Sticky turn bar: full-bleed ghost-active fill across the panel; title +
+ * metric labels sit in a centered 880 content lane (4×71 + 3×12 = 320). */
+
+.root {
+  position: sticky;
+  top: 0;
+  z-index: 1;
+  box-sizing: border-box;
+  width: 100%;
+  height: 44px;
+  background: var(--dsw-alias-button-ghost-active-fill);
+}
+
+.inner {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  box-sizing: border-box;
+  width: 100%;
+  max-width: 880px;
+  height: 100%;
+  margin: 0 auto;
+  padding: 0 16px;
+}
+
+.title {
+  flex: none;
+  font: var(--dsw-font-xs-strong-13);
+  color: var(--dsw-alias-label-primary);
+}
+
+.columns {
+  flex: none;
+  display: flex;
+  align-items: center;
+  width: 320px;
+  gap: 12px;
+  /* Match cell padding-right: 8 so Time lines up with the trailing lane. */
+  margin-right: 8px;
+}
+
+.column {
+  flex: none;
+  width: 71px;
+  text-align: left;
+  font: var(--dsw-font-xs-13);
+  color: var(--dsw-alias-label-secondary);
+}

+ 30 - 0
packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx

@@ -0,0 +1,30 @@
+// TrajectoryTurnHeader: sticky per-turn bar with Input/Output/Think/Time labels.
+
+import css from './TrajectoryTurnHeader.module.css'
+
+const COLUMN_LABELS = ['Input', 'Output', 'Think', 'Time'] as const
+
+export interface TrajectoryTurnHeaderProps {
+  /** 1-based turn index shown as `Turn N`. */
+  turn: number
+}
+
+/**
+ * Render the sticky turn header row.
+ * @param props.turn - turn index.
+ * @returns the sticky header element.
+ */
+export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) {
+  return (
+    <div className={css.root}>
+      <div className={css.inner}>
+        <span className={css.title}>Turn {turn}</span>
+        <div className={css.columns} aria-hidden="true">
+          {COLUMN_LABELS.map((label) => (
+            <span key={label} className={css.column}>{label}</span>
+          ))}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 30 - 20
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -1,30 +1,40 @@
-// TrajectoryView: P-I placeholder body for the trajectory tab — span stats
-// header over a per-turn span list with node-count weights (no timing data
-// exists yet; deviation ledger #3 defers real rendering to P-III).
+// TrajectoryView: sticky Turn sections with Message/Step groups and step cells.
 
 import { useMemo } from 'react'
 import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { deriveSpans } from './spans.ts'
-import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
+import { TrajectoryCell } from './TrajectoryCell.tsx'
+import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx'
+import { TrajectoryTurn } from './TrajectoryTurn.tsx'
+import { deriveTrajectoryLayout } from './layout.ts'
 import css from './views.module.css'
 
 export function TrajectoryView({ useSession }: ConvViewProps) {
   const nodes = useSession((s) => s.nodes)
-  const spans = useMemo(() => deriveSpans(nodes), [nodes])
-  if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
+  const partial = useSession((s) => s.partial)
+  const runningCalls = useSession((s) => s.runningCalls)
+  const turns = useMemo(
+    () => deriveTrajectoryLayout({ nodes, partial, runningCalls }),
+    [nodes, partial, runningCalls],
+  )
+  if (turns.length === 0) {
+    return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
+  }
   return (
-    <>
-      <TrajectoryStatsHeader useSession={useSession} />
-      <div className={css.root}>
-        {spans.map((span) => (
-          <div key={span.turn} className={css.row}>
-            <span className={css.turnTag}>turn {span.turn}</span>
-            <span className={css.meta}>
-              {span.steps} steps · {span.calls} calls · {span.nodes} nodes
-            </span>
-          </div>
-        ))}
-      </div>
-    </>
+    <div className={css.root}>
+      {turns.map((turn) => (
+        <TrajectoryTurn key={turn.turn} turn={turn.turn}>
+          {turn.groups.flatMap((group) => [
+            <TrajectoryGroupHeader
+              key={`${group.title}-h`}
+              title={group.title}
+              {...(group.description !== undefined ? { description: group.description } : {})}
+            />,
+            ...group.cells.map((cell) => (
+              <TrajectoryCell key={cell.index} {...cell} />
+            )),
+          ])}
+        </TrajectoryTurn>
+      ))}
+    </div>
   )
 }

+ 2 - 2
packages/client/ui-trajectory/src/client/index.ts

@@ -24,8 +24,8 @@ export const inject = ['slots', 'conversation']
 /**
  * Client plugin body: register the trajectory and waterfall view tabs. The
  * registrations ride the slot service's effect wrapper (plugin unload
- * removes both tabs); the span stats header renders inside each view body
- * (the chrome attachment mechanism retired with the view ring).
+ * removes both tabs). Trajectory owns its turn list in-body; Waterfall keeps
+ * the span stats header inside its body (chrome attachment retired).
  * @param ctx - client root context.
  */
 export function apply(ctx: Context): void {

+ 374 - 0
packages/client/ui-trajectory/src/client/layout.ts

@@ -0,0 +1,374 @@
+/**
+ * Trajectory list fold: expand assistant blocks, attach usage to Message,
+ * own-duration times, in-flight partial/runningCalls, and group descriptions.
+ */
+import type {
+  AssistantMessageNode,
+  ConversationSnapshot,
+  ToolResultNode,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import type { TrajectoryCellProps } from './TrajectoryCell.tsx'
+
+/** One Message or Step group inside a turn. */
+export interface TrajectoryGroupModel {
+  title: string
+  description?: string
+  cells: readonly TrajectoryCellProps[]
+}
+
+/** One sticky-turn section. */
+export interface TrajectoryTurnModel {
+  turn: number
+  groups: readonly TrajectoryGroupModel[]
+}
+
+/** Snapshot slice the trajectory view folds. */
+export interface TrajectoryLayoutInput {
+  nodes: ConversationSnapshot['nodes']
+  partial: ConversationSnapshot['partial']
+  runningCalls: ConversationSnapshot['runningCalls']
+}
+
+interface UsageLike {
+  inputTokens?: number
+  outputTokens?: number
+  reasoningTokens?: number
+}
+
+/** Cell plus absolute ms for group wall-span descriptions. */
+interface LaidCell {
+  cell: TrajectoryCellProps
+  absTime: number | null
+  toolName?: string
+  callId?: string
+}
+
+/**
+ * Fold a snapshot into turn → Message/Step groups with expanded cells.
+ * @param input - nodes plus in-flight partial/runningCalls.
+ * @returns turns ordered by first appearance.
+ */
+export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
+  const { nodes, partial, runningCalls } = input
+  const resultByCall = indexResults(nodes)
+  const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
+  let index = 0
+  let prevAbsTime: number | null = null
+
+  const bucket = (turn: number) => {
+    let entry = turns.get(turn)
+    if (entry === undefined) {
+      entry = { message: [], steps: new Map() }
+      turns.set(turn, entry)
+    }
+    return entry
+  }
+
+  const pushMessage = (turn: number, laid: LaidCell) => {
+    bucket(turn).message.push(laid)
+  }
+  const pushStep = (turn: number, step: number, laid: LaidCell) => {
+    const steps = bucket(turn).steps
+    const list = steps.get(step) ?? []
+    list.push(laid)
+    steps.set(step, list)
+  }
+
+  for (const node of nodes) {
+    if (node.kind === 'user' || node.kind === 'steering') {
+      const turn = node.kind === 'steering' ? node.turn : 0
+      pushMessage(turn, {
+        absTime: finiteTime(node.time),
+        cell: {
+          index: ++index, kind: 'user', text: summarizeContent(node.content),
+          timeSeconds: 0,
+        },
+      })
+      prevAbsTime = finiteTime(node.time) ?? prevAbsTime
+      continue
+    }
+    if (node.kind === 'assistant') {
+      const laidList = expandAssistant(node, index + 1, prevAbsTime, resultByCall)
+      for (const laid of laidList) {
+        if (node.step > 0) pushStep(node.turn, node.step, laid)
+        else pushMessage(node.turn, laid)
+      }
+      const last = laidList[laidList.length - 1]
+      if (last !== undefined) index = last.cell.index
+      prevAbsTime = finiteTime(node.time) ?? prevAbsTime
+      continue
+    }
+    if (node.kind === 'tool-result') {
+      if (!callEmittedInAssistant(nodes, node.callId)) {
+        const toolName = node.call?.name
+        pushStep(0, 1, {
+          absTime: finiteTime(node.callTime ?? node.time),
+          ...(toolName !== undefined ? { toolName } : {}),
+          callId: node.callId,
+          cell: {
+            index: ++index,
+            kind: 'tool',
+            text: node.call !== null
+              ? summarizeCall(node.call.name, node.call.argsRaw)
+              : summarizeResult(node),
+            timeSeconds: durationSeconds(node.time, node.callTime),
+          },
+        })
+      }
+      prevAbsTime = finiteTime(node.time) ?? prevAbsTime
+    }
+  }
+
+  if (partial !== null) {
+    const fake: AssistantMessageNode = {
+      kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
+      turn: partial.turn, step: partial.step, blocks: partial.blocks,
+    }
+    const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true })
+    for (const laid of laidList) {
+      if (partial.step > 0) pushStep(partial.turn, partial.step, laid)
+      else pushMessage(partial.turn, laid)
+    }
+    const last = laidList[laidList.length - 1]
+    if (last !== undefined) index = last.cell.index
+  }
+
+  const seenCalls = collectCallIds(turns)
+  for (const call of runningCalls) {
+    if (seenCalls.has(call.callId)) continue
+    pushStep(call.turn, call.step > 0 ? call.step : 1, {
+      absTime: null,
+      toolName: call.name,
+      callId: call.callId,
+      cell: {
+        index: ++index,
+        kind: 'tool',
+        text: summarizeCall(call.name, call.argsRaw),
+        timeSeconds: null,
+      },
+    })
+  }
+
+  const prologue = turns.get(0)
+  if (prologue !== undefined) {
+    turns.delete(0)
+    const emptyTurn = (): { message: LaidCell[]; steps: Map<number, LaidCell[]> } => ({
+      message: [],
+      steps: new Map(),
+    })
+    const first = turns.get(1) ?? emptyTurn()
+    first.message = [...prologue.message, ...first.message]
+    for (const [step, cells] of prologue.steps) {
+      const existing = first.steps.get(step) ?? []
+      first.steps.set(step, [...cells, ...existing])
+    }
+    turns.set(1, first)
+  }
+
+  return [...turns.entries()]
+    .sort(([a], [b]) => a - b)
+    .map(([turn, entry]) => toTurnModel(turn, entry))
+}
+
+function toTurnModel(
+  turn: number,
+  entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> },
+): TrajectoryTurnModel {
+  const groups: TrajectoryGroupModel[] = []
+  if (entry.message.length > 0) {
+    const description = groupDescription(entry.message)
+    groups.push({
+      title: 'Message',
+      ...(description !== undefined ? { description } : {}),
+      cells: entry.message.map(l => l.cell),
+    })
+  }
+  for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) {
+    const laid = entry.steps.get(step) ?? []
+    const description = groupDescription(laid)
+    groups.push({
+      title: `Step ${step}`,
+      ...(description !== undefined ? { description } : {}),
+      cells: laid.map(l => l.cell),
+    })
+  }
+  return { turn, groups }
+}
+
+/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */
+function groupDescription(laid: readonly LaidCell[]): string | undefined {
+  const parts: string[] = []
+  // Tool rows contribute start (absTime) and end (start + own duration) so a
+  // single Tool cell still spans call→result for the group wall clock.
+  const times: number[] = []
+  for (const l of laid) {
+    if (l.absTime === null || !Number.isFinite(l.absTime)) continue
+    times.push(l.absTime)
+    if (l.cell.kind === 'tool' && l.cell.timeSeconds !== null && Number.isFinite(l.cell.timeSeconds)) {
+      times.push(l.absTime + l.cell.timeSeconds * 1000)
+    }
+  }
+  if (times.length >= 2) {
+    const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000)
+    if (span !== undefined) parts.push(span)
+  } else if (times.length === 1) {
+    const own = laid.find(l => l.absTime === times[0])?.cell.timeSeconds
+    const span = own !== null && own !== undefined ? formatGroupDuration(own) : undefined
+    if (span !== undefined) parts.push(span)
+  }
+  const tools = new Map<string, number>()
+  for (const l of laid) {
+    if (l.toolName === undefined || l.cell.kind !== 'tool') continue
+    tools.set(l.toolName, (tools.get(l.toolName) ?? 0) + 1)
+  }
+  for (const [name, count] of tools) {
+    parts.push(count > 1 ? `${name}×${count}` : name)
+  }
+  return parts.length === 0 ? undefined : parts.join(' ')
+}
+
+function formatGroupDuration(seconds: number): string | undefined {
+  if (!Number.isFinite(seconds)) return undefined
+  const rounded = Math.round(seconds * 10) / 10
+  if (Number.isInteger(rounded)) return `${rounded}s`
+  return `${rounded.toFixed(1)}s`
+}
+
+/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
+function durationSeconds(later: number, earlier: number | null): number | null {
+  if (earlier === null || !Number.isFinite(later) || !Number.isFinite(earlier)) return null
+  return Math.max(0, (later - earlier) / 1000)
+}
+
+/** Epoch-ms usable as an absolute time, else null. */
+function finiteTime(time: number): number | null {
+  return Number.isFinite(time) ? time : null
+}
+
+function expandAssistant(
+  node: AssistantMessageNode,
+  startIndex: number,
+  prevAbsTime: number | null,
+  results: Map<string, ToolResultNode>,
+  opts?: { streaming?: boolean },
+): LaidCell[] {
+  const out: LaidCell[] = []
+  let index = startIndex - 1
+  const usage = node.usage as UsageLike | undefined
+  const streaming = opts?.streaming === true
+  const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime)
+  const nodeAbs = streaming ? null : finiteTime(node.time)
+  let usageAttached = false
+
+  for (const block of node.blocks) {
+    // Reasoning blocks are skipped: no block-level clock, so no Think cell.
+    if (block.kind === 'reasoning') continue
+    if (block.kind === 'text') {
+      if (block.text === '' && streaming) continue
+      const cell: TrajectoryCellProps = {
+        index: ++index, kind: 'message', text: summarizeText(block.text),
+        timeSeconds: messageDuration,
+      }
+      if (!usageAttached && usage !== undefined) {
+        if (usage.inputTokens !== undefined) cell.input = usage.inputTokens
+        if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
+        if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
+        usageAttached = true
+      }
+      out.push({ absTime: nodeAbs, cell })
+      continue
+    }
+    if (block.kind === 'tool-call') {
+      const result = results.get(block.callId)
+      const toolDuration = streaming || result === undefined
+        ? null
+        : durationSeconds(result.time, result.callTime)
+      const callAbs = streaming
+        ? null
+        : (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime)
+          ? result.callTime
+          : nodeAbs)
+      out.push({
+        absTime: callAbs,
+        toolName: block.name,
+        callId: block.callId,
+        cell: {
+          index: ++index, kind: 'tool',
+          text: summarizeCall(block.name, block.argsRaw),
+          timeSeconds: toolDuration,
+        },
+      })
+    }
+  }
+
+  if (out.length === 0 && !streaming) {
+    out.push({
+      absTime: nodeAbs,
+      cell: { index: ++index, kind: 'message', text: '', timeSeconds: messageDuration },
+    })
+  }
+  return out
+}
+
+function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolResultNode> {
+  const map = new Map<string, ToolResultNode>()
+  for (const node of nodes) {
+    if (node.kind === 'tool-result') map.set(node.callId, node)
+  }
+  return map
+}
+
+function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean {
+  for (const node of nodes) {
+    if (node.kind !== 'assistant') continue
+    if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true
+  }
+  return false
+}
+
+function collectCallIds(
+  turns: Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>,
+): Set<string> {
+  const ids = new Set<string>()
+  for (const entry of turns.values()) {
+    for (const laid of entry.message) {
+      if (laid.callId !== undefined) ids.add(laid.callId)
+    }
+    for (const list of entry.steps.values()) {
+      for (const laid of list) {
+        if (laid.callId !== undefined) ids.add(laid.callId)
+      }
+    }
+  }
+  return ids
+}
+
+function summarizeCall(name: string, argsRaw: string): string {
+  const args = argsRaw.replace(/\s+/g, ' ').trim()
+  if (args === '') return name
+  const clipped = args.length > 72 ? `${args.slice(0, 71)}…` : args
+  return `${name} · ${clipped}`
+}
+
+function summarizeResult(node: ToolResultNode): string {
+  if (node.isError) {
+    return node.error?.code ?? 'error'
+  }
+  for (const block of node.content) {
+    if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
+      return summarizeText(block.text)
+    }
+  }
+  return node.call?.name ?? node.callId
+}
+
+function summarizeContent(content: readonly { type: string; text?: string }[]): string {
+  for (const block of content) {
+    if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text)
+  }
+  return ''
+}
+
+function summarizeText(text: string): string {
+  return text.replace(/\s+/g, ' ').trim()
+}

+ 14 - 4
packages/client/ui-trajectory/src/client/views.module.css

@@ -1,25 +1,34 @@
+/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge;
+ * cell content width is capped on the turn body (max 880). */
 .root {
-  padding: 16px;
   overflow-y: auto;
+  height: 100%;
+  min-height: 0;
+  width: 100%;
+  box-sizing: border-box;
   color: var(--dsw-alias-label-primary);
-  font-size: 13px;
+  background: var(--dsw-specific-sidebar-fill);
 }
 
 .empty {
+  padding: 16px;
   color: var(--dsw-alias-label-tertiary);
+  font: var(--dsw-font-xs-13);
 }
 
+/* Waterfall placeholder rows (shared module). */
 .row {
   display: flex;
   align-items: center;
   gap: 8px;
-  padding: 4px 0;
+  padding: 4px 16px;
 }
 
 .turnTag {
   flex: none;
   width: 64px;
   color: var(--dsw-alias-label-secondary);
+  font: var(--dsw-font-xs-13);
 }
 
 .bar {
@@ -29,9 +38,10 @@
 }
 
 .barCalls {
-  background: var(--dsw-alias-brand-primary);
+  background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
 }
 
 .meta {
   color: var(--dsw-alias-label-caption);
+  font: var(--dsw-font-xs-13);
 }

+ 87 - 0
packages/client/ui-trajectory/tests/cell.spec.tsx

@@ -0,0 +1,87 @@
+// @vitest-environment jsdom
+/**
+ * TrajectoryCell presentation: kind tags, ellipsis-hosting text, Message
+ * metric columns, own-duration formatting, and selected ring.
+ */
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, render, screen } from '@testing-library/react'
+import {
+  formatElapsedSeconds,
+  TrajectoryCell,
+  type TrajectoryCellKind,
+} from '../src/client/TrajectoryCell.tsx'
+
+afterEach(cleanup)
+
+describe('formatElapsedSeconds', () => {
+  it('formats known durations and uses an em dash when absent', () => {
+    expect(formatElapsedSeconds(null)).toBe('—')
+    expect(formatElapsedSeconds(235)).toBe('+235s')
+    expect(formatElapsedSeconds(235.0)).toBe('+235s')
+    expect(formatElapsedSeconds(235.2)).toBe('+235.2s')
+    expect(formatElapsedSeconds(235.25)).toBe('+235.3s')
+    expect(formatElapsedSeconds(0)).toBe('+0s')
+    expect(formatElapsedSeconds(Number.NaN)).toBe('—')
+  })
+})
+
+describe('TrajectoryCell', () => {
+  it('renders index, kind tag, text, and time for a Tool row', () => {
+    render(
+      <TrajectoryCell
+        index={6}
+        kind="tool"
+        text="bash · Read src/index.ts"
+        timeSeconds={5}
+      />,
+    )
+    expect(screen.getByText('#6')).toBeTruthy()
+    expect(screen.getByText('Tool')).toBeTruthy()
+    expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy()
+    expect(screen.getByText('+5s')).toBeTruthy()
+  })
+
+  it('Message rows expose Input / Output / Think metric columns before time', () => {
+    const { container } = render(
+      <TrajectoryCell
+        index={3}
+        kind="message"
+        text="Let me now read the actual source files to understa..."
+        timeSeconds={235.2}
+        input={136}
+        output={381}
+        think={155}
+      />,
+    )
+    expect(screen.getByText('Message')).toBeTruthy()
+    expect(screen.getByText('136')).toBeTruthy()
+    expect(screen.getByText('381')).toBeTruthy()
+    expect(screen.getByText('155')).toBeTruthy()
+    expect(screen.getByText('+235.2s')).toBeTruthy()
+    const texts = [...container.querySelectorAll('span')].map((el) => el.textContent)
+    expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
+    expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
+    expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))
+  })
+
+  it('selected marks the row for the brand-primary inset ring', () => {
+    const { container } = render(
+      <TrajectoryCell index={15} kind="message" text="pictur..." timeSeconds={123.6} selected />,
+    )
+    expect(container.firstElementChild?.getAttribute('data-selected')).toBe('true')
+  })
+
+  it.each([
+    ['user', 'User'],
+    ['tool', 'Tool'],
+  ] as const)('kind %s shows the %s tag and no metric columns', (kind: TrajectoryCellKind, label: string) => {
+    const { container } = render(
+      <TrajectoryCell index={1} kind={kind} text="summary" timeSeconds={kind === 'user' ? 0 : null} input={1} output={2} think={3} />,
+    )
+    expect(screen.getByText(label)).toBeTruthy()
+    expect(container.querySelector('[data-kind]')?.getAttribute('data-kind')).toBe(kind)
+    expect(screen.queryByText('1')).toBeNull()
+    expect(screen.queryByText('2')).toBeNull()
+    expect(screen.queryByText('3')).toBeNull()
+  })
+})

+ 143 - 0
packages/client/ui-trajectory/tests/layout.spec.tsx

@@ -0,0 +1,143 @@
+// @vitest-environment jsdom
+/**
+ * Trajectory turn chrome and layout fold: expand blocks, usage on Message,
+ * tool own-duration, group wall-span descriptions, in-flight rows.
+ */
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, render, screen } from '@testing-library/react'
+import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
+import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
+import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
+import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
+import { deriveTrajectoryLayout } from '../src/client/layout.ts'
+
+afterEach(cleanup)
+
+describe('TrajectoryTurnHeader', () => {
+  it('renders Turn N and the four metric column labels', () => {
+    render(<TrajectoryTurnHeader turn={1} />)
+    expect(screen.getByText('Turn 1')).toBeTruthy()
+    expect(screen.getByText('Input')).toBeTruthy()
+    expect(screen.getByText('Output')).toBeTruthy()
+    expect(screen.getByText('Think')).toBeTruthy()
+    expect(screen.getByText('Time')).toBeTruthy()
+  })
+})
+
+describe('TrajectoryGroupHeader', () => {
+  it('renders title and optional description', () => {
+    render(<TrajectoryGroupHeader title="Step 1" description="2.2s skill" />)
+    expect(screen.getByText('Step 1')).toBeTruthy()
+    expect(screen.getByText('2.2s skill')).toBeTruthy()
+  })
+
+  it('omits the description node when absent', () => {
+    const { container } = render(<TrajectoryGroupHeader title="Message" />)
+    expect(screen.getByText('Message')).toBeTruthy()
+    expect(container.querySelectorAll('span')).toHaveLength(1)
+  })
+})
+
+describe('TrajectoryTurn', () => {
+  it('wraps a sticky header and body children', () => {
+    render(
+      <TrajectoryTurn turn={3}>
+        <TrajectoryGroupHeader title="Message" description="49s" />
+      </TrajectoryTurn>,
+    )
+    expect(screen.getByText('Turn 3')).toBeTruthy()
+    expect(screen.getByText('Message')).toBeTruthy()
+    expect(screen.getByText('49s')).toBeTruthy()
+  })
+})
+
+describe('deriveTrajectoryLayout', () => {
+  it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => {
+    const nodes = [
+      { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null },
+      {
+        kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
+        blocks: [
+          { kind: 'reasoning', text: 'thinking…' },
+          { kind: 'text', text: 'I will run bash' },
+          { kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls"}' },
+        ],
+        usage: { inputTokens: 10, outputTokens: 20, reasoningTokens: 5 },
+      },
+      {
+        kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1',
+        call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200,
+        content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
+      },
+    ] as unknown as ConversationSnapshot['nodes']
+    const turns = deriveTrajectoryLayout({ 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))
+    expect(kinds).toEqual(['user', 'message', 'tool'])
+    const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
+    expect(message).toMatchObject({
+      input: 10, output: 20, think: 5, timeSeconds: 5,
+    })
+    const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool')
+    expect(tool?.text).toBe('bash · {"command":"ls"}')
+    expect(tool?.timeSeconds).toBe(1.3)
+  })
+
+  it('adds runningCalls not already present and leaves their time blank', () => {
+    const turns = deriveTrajectoryLayout({
+      nodes: [] as unknown as ConversationSnapshot['nodes'],
+      partial: null,
+      runningCalls: [{
+        callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
+        turn: 1, step: 2, time: 9_000, callView: null,
+      }],
+    })
+    expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2'])
+    expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
+      kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
+    })
+  })
+
+  it('omits duration when node times are missing instead of rendering NaN', () => {
+    const nodes = [
+      { kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },
+      {
+        kind: 'assistant', seq: 2, turn: 1, step: 1,
+        blocks: [
+          { kind: 'reasoning', text: '…' },
+          { kind: 'text', text: 'ok' },
+        ],
+        usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
+      },
+    ] as unknown as ConversationSnapshot['nodes']
+    const turns = deriveTrajectoryLayout({ 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()
+  })
+
+  it('builds a wall-span step description with a tool histogram', () => {
+    const nodes = [
+      {
+        kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1,
+        blocks: [
+          { kind: 'tool-call', callId: 'a', name: 'bash', argsRaw: '{}' },
+          { kind: 'tool-call', callId: 'b', name: 'bash', argsRaw: '{}' },
+        ],
+      },
+      {
+        kind: 'tool-result', seq: 2, time: 2_500, callId: 'a',
+        call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100,
+        content: [], isError: false, callView: null, resultView: null,
+      },
+      {
+        kind: 'tool-result', seq: 3, time: 4_000, callId: 'b',
+        call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600,
+        content: [], isError: false, callView: null, resultView: null,
+      },
+    ] as unknown as ConversationSnapshot['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
+  })
+})

+ 26 - 15
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -3,9 +3,9 @@
  * View registration acceptance on the real framework stack: the plugin fiber
  * registers trajectory/waterfall into a real SlotsService view ring, tabs
  * switch inside ConversationRoot (renderSlot share driven by the same tab
- * projection apply uses) without collapsing chat, the span stats header
- * renders inside both view bodies, and fiber disposal removes both tabs.
- * Span derivation edge cases ride along.
+ * projection apply uses) without collapsing chat, trajectory renders the
+ * turn-list chrome (no span stats bar), waterfall keeps in-body stats, and
+ * fiber disposal removes both tabs. Span derivation edge cases ride along.
  */
 import { Context } from 'cordis'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -36,19 +36,26 @@ afterEach(cleanup)
 // The chat store persists under its declared key; clear so one case's active
 // view cannot rehydrate into the next.
 beforeEach(() => {
-  localStorage.clear()
+  // Node 22+ exposes an experimental localStorage global that is undefined
+  // without --localstorage-file; only clear when a real Storage is present.
+  if (typeof localStorage !== 'undefined') localStorage.clear()
 })
 
 /** Node fixture: user prologue, two turns, one tool result inside turn 1. */
 const NODES = [
-  { kind: 'user', seq: 1, content: [], source: null },
-  { kind: 'assistant', seq: 2, turn: 1, step: 1, blocks: [] },
-  { kind: 'tool-result', seq: 3, callId: 'c1', call: null, content: [], isError: false, callView: null, resultView: null },
-  { kind: 'assistant', seq: 4, turn: 2, step: 1, blocks: [] },
+  { kind: 'user', seq: 1, time: 1_000, content: [], source: null },
+  { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] },
+  {
+    kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null,
+    content: [], isError: false, callView: null, resultView: null,
+  },
+  { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] },
 ] as unknown as ConversationSnapshot['nodes']
 
 function fakeSession(nodes: ConversationSnapshot['nodes']) {
-  const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes })
+  const store = createSnapshotStore({
+    nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
+  })
   return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
 }
 
@@ -99,8 +106,9 @@ function tabsOf(slots: SlotsService): ViewTab[] {
 
 /** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */
 function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
-  const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({
+  const sessionSnapshot = createSnapshotStore({
     running: false, removed: false, promptError: null, nodes,
+    partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
   })
   const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
   const chat = createChatStore().create()
@@ -158,17 +166,20 @@ describe('plugin registration', () => {
 })
 
 describe('tab switching in ConversationRoot', () => {
-  it('renders all three tabs, defaults to chat, and switches to trajectory with its header stats', async () => {
+  it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => {
     const b = await bench()
     mount(b.slots)
     expect(screen.getByTestId('chat-body')).toBeTruthy()
     expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
 
     fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
-    // In-body header stats over NODES: turns 0/1/2, 2 assistant steps, 1 tool call.
-    expect(screen.getByText('3 turns · 2 steps · 1 tool calls')).toBeTruthy()
-    expect(screen.getByText('turn 0')).toBeTruthy()
-    expect(screen.getByText('1 steps · 1 calls · 2 nodes')).toBeTruthy()
+    // Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body.
+    expect(screen.queryByText(/turns ·/)).toBeNull()
+    expect(screen.getByText('Turn 1')).toBeTruthy()
+    expect(screen.getByText('Turn 2')).toBeTruthy()
+    expect(screen.getAllByText('Message').length).toBeGreaterThan(0)
+    expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0)
+    expect(screen.getAllByText('Input').length).toBeGreaterThan(0)
     expect(screen.queryByTestId('chat-body')).toBeNull()
   })