Просмотр исходного кода

feat(ui): add trajectory inspection ledger

_Kerman 1 месяц назад
Родитель
Сommit
628c1bffe0
42 измененных файлов с 4547 добавлено и 176 удалено
  1. 6 0
      .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml
  2. 36 0
      .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
  3. 36 0
      .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md
  4. 13 6
      apps/web/tests/navigation-panes.e2e.ts
  5. 57 1
      apps/web/tests/snapshots/navigation-panes/trajectory.expected.md
  6. 1 1
      packages/client/runtime/src/client/index.ts
  7. 15 1
      packages/client/runtime/src/client/sessions/conversation.ts
  8. 48 2
      packages/client/runtime/src/client/sessions/fold-adapter.ts
  9. 32 1
      packages/client/runtime/src/client/sessions/session.ts
  10. 4 4
      packages/client/ui-conversation/src/client/apply.ts
  11. 2 0
      packages/client/ui-conversation/src/client/contract/slots.ts
  12. 8 6
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  13. 8 7
      packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
  14. 1 1
      packages/client/ui-layout/src/client/AppFrame.module.css
  15. 4 0
      packages/client/ui-primitives/package.json
  16. 221 0
      packages/client/ui-primitives/src/JsonTree.module.css
  17. 390 0
      packages/client/ui-primitives/src/JsonTree.tsx
  18. 31 0
      packages/client/ui-primitives/src/Menu.module.css
  19. 5 3
      packages/client/ui-primitives/src/Menu.tsx
  20. 4 0
      packages/client/ui-primitives/src/index.ts
  21. 126 0
      packages/client/ui-primitives/src/markdown/plain-text.ts
  22. 42 0
      packages/client/ui-primitives/tests/markdown-plain-text.spec.ts
  23. 3 3
      packages/client/ui-trajectory/README.i18n.yaml
  24. 2 2
      packages/client/ui-trajectory/README.md
  25. 2 2
      packages/client/ui-trajectory/README.zh.md
  26. 3 1
      packages/client/ui-trajectory/package.json
  27. 23 40
      packages/client/ui-trajectory/src/client/TrajectoryCell.tsx
  28. 1067 0
      packages/client/ui-trajectory/src/client/TrajectoryTable.module.css
  29. 1301 0
      packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
  30. 85 0
      packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css
  31. 66 0
      packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx
  32. 92 23
      packages/client/ui-trajectory/src/client/TrajectoryView.tsx
  33. 1 1
      packages/client/ui-trajectory/src/client/WaterfallView.tsx
  34. 239 51
      packages/client/ui-trajectory/src/client/layout.ts
  35. 81 0
      packages/client/ui-trajectory/src/client/trajectory-record.ts
  36. 12 5
      packages/client/ui-trajectory/src/client/views.module.css
  37. 8 7
      packages/client/ui-trajectory/tests/layout.spec.tsx
  38. 88 0
      packages/client/ui-trajectory/tests/table.spec.tsx
  39. 34 8
      packages/client/ui-trajectory/tests/views.spec.tsx
  40. 319 0
      patches/react-json-view-lite@2.5.0.patch
  41. 28 0
      pnpm-lock.yaml
  42. 3 0
      pnpm-workspace.yaml

+ 6 - 0
.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.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 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
+2026-07-27-trajectory-inspection-ledger.md: 30d1a2b0b43c8ca134f934c7197f85ff03c65742
+2026-07-27-trajectory-inspection-ledger.zh.md: 2c8724160ca25a4bd9ae4cdddd5b1e07bc1d808c

+ 36 - 0
.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md

@@ -0,0 +1,36 @@
+# Agent Note: Trajectory inspection ledger
+
+Status: implemented
+
+English | [中文](2026-07-27-trajectory-inspection-ledger.zh.md)
+
+## Problem
+
+Trajectory has to make prose, machine payloads, token usage, timing, and nested tool activity readable in the same viewport. The earlier stacked Turn and Step cards preserved hierarchy but spent too much vertical space on repeated chrome, while a completely flat table would erase the causal structure that makes a trajectory useful. Role colors also risked borrowing success and warning semantics, which made visual decoration indistinguishable from runtime state.
+
+## Decision
+
+**Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.**
+
+- Turn boundaries are thick rules between record rows, while each Step appears as a compact inline marker on its first record. Individual User, Assistant, Tool, and Subtool events share stable columns for index, event kind, and content; token usage and duration stay in the inspector, a thin timeline rail preserves sequence, and nested subtools receive a small indentation.
+- Product prose continues to use the existing sans stack. Record indexes, token counts, durations, group summaries, tool calls, and raw payloads use the existing code stack because they are machine data.
+- Existing semantic theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; business blue is limited to Assistant identity, selection, links, and focus; warning is limited to running work; error is limited to failed work. User and Tool roles do not impersonate runtime states.
+- Entity surfaces stay flat and separated by hairline borders. Shadow appears only when the inspector becomes an overlay at narrow widths.
+- Selecting a record opens an inspector inside Trajectory with Overview, Input, Output, and Timing tabs. This state is deliberately independent from the conversation-wide Chat details column: it inspects a trajectory record without changing the user's Chat context.
+- The three-column ledger reserves its width for record content. At narrow widths the inspector overlays the ledger and remains dismissible by keyboard or pointer.
+
+## Alternatives considered
+
+**Copy Vite DevTools fonts, colors, glass surfaces, or component shapes.** Rejected: those choices express a different product identity. The implementation only adopts the transferable method: neutral structure, semantic accents, machine-data typography, dense scanning, and shadows reserved for floating layers.
+
+**Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower.
+
+**Flatten every record without turn rules or step markers.** Rejected: a trajectory is not merely a log stream; Turn and Step boundaries are essential causal landmarks even when they do not consume dedicated rows.
+
+**Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state.
+
+**Change global theme tokens to match the reference.** Rejected: the existing theme already provides paired light and dark semantic layers, and a local redesign does not justify changing unrelated surfaces.
+
+## Consequences
+
+Trajectory shows more useful records per viewport while retaining Turn and Step orientation. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payload and assistant timing. The inspector floats over the table only when a permanent split would make both panes unusable. Focused component tests pin the ledger, fold control, keyboard selection, payload tabs, timing facts, and running/error semantics; the assembled Web snapshot pins the real seeded session with the local inspector open.

+ 36 - 0
.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md

@@ -0,0 +1,36 @@
+# Agent Note:轨迹检查记录表
+
+Status: implemented
+
+[English](2026-07-27-trajectory-inspection-ledger.md) | 中文
+
+## 问题
+
+轨迹视图需要在同一视口内清晰呈现正文、机器载荷、token 用量、计时数据和嵌套工具活动。此前堆叠式的轮次与步骤卡片虽然保留了层级,却在重复界面框架上耗费了太多垂直空间;完全扁平化的表格又会抹去因果结构,而这种结构正是轨迹视图的价值所在。角色配色还可能借用成功与警告语义,使视觉装饰与运行时状态无法区分。
+
+## 决策
+
+**使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。**
+
+- 轮次边界由记录行之间较粗的分割线表示,每个步骤在其首条记录上以紧凑的行内标记呈现。用户、助手、工具和子工具事件共用稳定的索引、事件类型和内容列;token 用量与耗时留在检查器中,细线时间轴保留事件顺序,嵌套子工具则采用小幅缩进。
+- 产品正文继续使用现有无衬线字体栈。记录索引、token 数、耗时、分组摘要、工具调用和原始载荷属于机器数据,因此使用现有代码字体栈。
+- 现有语义主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;业务蓝色仅用于助手身份、选择状态、链接和焦点;警告色仅用于运行中的工作;错误色仅用于失败的工作。用户和工具角色不借用运行时状态的视觉语义。
+- 各记录表面保持平面化,并以细线边框分隔。只有在窄屏下检查器变为浮层时才使用阴影。
+- 选择记录后,轨迹视图内部会打开包含概览、输入、输出和计时标签页的检查器。该状态有意与会话级 Chat 详情栏相互独立:检查轨迹记录不会改变用户在 Chat 中的上下文。
+- 三列记录表将宽度留给记录内容。在窄屏下,检查器会覆盖在记录表上,并且仍可通过键盘或指针关闭。
+
+## 曾考虑的替代方案
+
+**照搬 Vite DevTools 的字体、颜色、玻璃表面或组件形状。** 不予采纳:这些选择表达的是另一种产品身份。实现仅吸收可迁移的方法,即中性结构、语义强调色、机器数据排版、紧凑扫读,以及只为浮层保留阴影。
+
+**每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。
+
+**不使用轮次分割线与步骤标记,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;即使轮次与步骤边界不再占用独立行,它们仍是不可缺少的关键因果标记。
+
+**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。
+
+**修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。
+
+## 后果
+
+轨迹视图在保留轮次与步骤定位的同时,每个视口可以显示更多有效记录。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器则将这些数据与完整载荷、助手计时一并展示。只有固定分栏会让两个面板都无法使用时,检查器才浮在表格之上。针对性组件测试锁定事件记录表、折叠控制、键盘选择、载荷标签页、计时数据和运行/错误语义;组装后的 Web 快照则锁定真实预置会话在局部检查器打开时的渲染结果。

+ 13 - 6
apps/web/tests/navigation-panes.e2e.ts

@@ -124,17 +124,24 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
   }, 60_000)
 
-  it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => {
+  it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
     await page.getByRole('tab', { name: 'Trajectory' }).click()
-    // Two sticky turn sections; turn 1's step group summarizes its tool mix
-    // (bash + the two parallel reads collapse to 'bash read×2').
-    await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
-    await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
-    await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
+    // Thick row rules mark the two turns; compact inline markers identify
+    // each step without introducing dedicated group rows.
+    await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
+    await expect.poll(() => page.locator('[aria-label^="Step "]').count(), { timeout: 10_000 }).toBe(3)
+    await expect.poll(() => page.getByRole('columnheader', { name: 'Tokens' }).count(), { timeout: 10_000 }).toBe(0)
+    await expect.poll(() => page.getByRole('columnheader', { name: '耗时' }).count(), { timeout: 10_000 }).toBe(0)
+    await page.locator('tr[data-kind="tool"]').first().click()
+    await expect.poll(() => page.getByRole('complementary', { name: '记录详情' }).count(), { timeout: 10_000 }).toBe(1)
+    await page.getByRole('tab', { name: '输出' }).click()
+    await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
     const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
+    await page.getByRole('complementary', { name: '记录详情' })
+      .getByRole('button', { name: '关闭详情' }).click()
   }, 60_000)
 
   it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => {

+ 57 - 1
apps/web/tests/snapshots/navigation-panes/trajectory.expected.md

@@ -1 +1,57 @@
-- text: "Turn 1 Message {{duration}} #1 User NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. +{{duration}} Step 1 {{duration}} bash read×2 #2 Tool bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"} +{{duration}} #3 Tool read · {\"file_path\": \"nav-a.md\"} +{{duration}} #4 Tool read · {\"file_path\": \"nav-b.md\"} +{{duration}} Step 2 {{duration}} #5 Message FIRST_DONE 349 56 51 +{{duration}} Turn 2 Message {{duration}} #6 User Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. +{{duration}} Step 1 {{duration}} #7 Message ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ``` 141 36 16 +{{duration}}"
+- toolbar "轨迹工具栏":
+  - text: 轨迹
+  - strong: "8"
+  - text: 条记录
+  - strong: "2"
+  - text: 轮
+  - strong: "3"
+  - text: 次工具
+  - button "收起记录"
+- table:
+  - rowgroup:
+    - row "# 事件 内容":
+      - columnheader "#"
+      - columnheader "事件"
+      - columnheader "内容"
+  - rowgroup:
+    - 'row "记录 1,USER,NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."':
+      - cell "#1"
+      - cell "USER"
+      - 'cell "NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."'
+    - row "记录 2,ASSISTANT,请求调用 bash、read×2":
+      - cell "#2"
+      - cell "Step 1 ASSISTANT": S1 ASSISTANT
+      - cell "请求调用 bash、read×2"
+    - 'row "记录 3,TOOL,bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}" [selected]':
+      - cell "#3"
+      - cell "TOOL"
+      - 'cell "bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}→NAVIGATION_OK"'
+    - 'row "记录 4,TOOL,read · {\"file_path\": \"nav-a.md\"}"':
+      - cell "#4"
+      - cell "TOOL"
+      - 'cell "read · {\"file_path\": \"nav-a.md\"}→<path>{{cwd}}/nav-a.md</path> <type>file</type> <content> 1: # alpha nav (End of file - total 1 lines) </content>"'
+    - 'row "记录 5,TOOL,read · {\"file_path\": \"nav-b.md\"}"':
+      - cell "#5"
+      - cell "TOOL"
+      - 'cell "read · {\"file_path\": \"nav-b.md\"}→<path>{{cwd}}/nav-b.md</path> <type>file</type> <content> 1: # beta nav (End of file - total 1 lines) </content>"'
+    - row "记录 6,ASSISTANT,FIRST_DONE":
+      - cell "#6"
+      - cell "Step 2 ASSISTANT": S2 ASSISTANT
+      - cell "FIRST_DONE"
+    - 'row "记录 7,USER,Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."':
+      - cell "#7"
+      - cell "USER"
+      - 'cell "Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."'
+    - 'row "记录 8,ASSISTANT,## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"':
+      - cell "#8"
+      - cell "Step 1 ASSISTANT": S1 ASSISTANT
+      - 'cell "## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"'
+- complementary "记录详情":
+  - text: "TOOL 记录 #3 Turn 1 · Step 1"
+  - button "关闭详情"
+  - tablist "记录详情":
+    - tab "概览"
+    - tab "输入"
+    - tab "输出" [selected]
+    - tab "计时"
+  - tabpanel "输出": NAVIGATION_OK

+ 1 - 1
packages/client/runtime/src/client/index.ts

@@ -28,7 +28,7 @@ export type {
   EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
 } from './contract/store.ts'
 export type {
-  AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
+  AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
   ConversationSnapshot, QueuedMessage, RunningToolCall,
   SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
 } from './sessions/conversation.ts'

+ 15 - 1
packages/client/runtime/src/client/sessions/conversation.ts

@@ -3,7 +3,7 @@
 // substructures keep their references (the React.memo premise). callId/approvalId stay plain
 // string here (narrow to real brands when convenient).
 
-import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
 import type {
   RpcError, SessionId, ToolCallView, ToolResultView,
 } from '@deepseek-ai/dsh-client-connection/client'
@@ -50,6 +50,16 @@ export interface UserMessageNode {
   source: unknown
 }
 
+/** Recorded boundaries used to derive assistant latency and throughput. */
+export interface AssistantTiming {
+  /** Matching step/start timestamp, or null when it is outside the current event window. */
+  stepStartTime: number | null
+  /** First non-empty text/reasoning/tool delta timestamp, or null when no token delta was recorded. */
+  firstTokenTime: number | null
+  /** Final assistant/message timestamp. */
+  completedTime: number
+}
+
 /** A finalized (or interruption-frozen) assistant message. */
 export interface AssistantMessageNode {
   kind: 'assistant'
@@ -60,6 +70,8 @@ export interface AssistantMessageNode {
   step: number
   blocks: readonly AssistantBlock[]
   usage?: unknown
+  /** Timing derived from the recorded step/chunk/message event sequence. */
+  timing?: AssistantTiming
   /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
    *  Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */
   interrupted?: true
@@ -216,6 +228,8 @@ export interface ConversationSnapshot {
    * unrelated snapshot swaps (memo premise, same regime as `nodes`).
    */
   codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
+  /** Model-visible tool schema captured for each recorded call id. */
+  callSchemas?: ReadonlyMap<string, ToolSchema>
   pending: readonly PendingInteraction[]
   /** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */
   queue: readonly QueuedMessage[]

+ 48 - 2
packages/client/runtime/src/client/sessions/fold-adapter.ts

@@ -9,7 +9,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 // browser bundle cannot resolve; surface.ts has no Node dependencies.
 import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
 import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
-import type { ConversationNode } from './conversation.ts'
+import type { AssistantTiming, ConversationNode } from './conversation.ts'
 import { toAssistantBlocks } from './conversation.ts'
 
 /** In-window tool/call index entry (result-card backfill + runningCalls material). */
@@ -37,6 +37,7 @@ function materializeNode(
   event: SessionEvent,
   callIndex: ReadonlyMap<string, CallIndexEntry>,
   resultView: ToolResultView | null,
+  assistantTiming?: AssistantTiming,
 ): ConversationNode {
   switch (event.type) {
     case 'user/message':
@@ -58,6 +59,7 @@ function materializeNode(
         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,
+        ...(assistantTiming !== undefined ? { timing: assistantTiming } : {}),
       }
     case 'steering/message':
       return {
@@ -177,7 +179,12 @@ export class FoldAdapter {
       const event = this.padded[seq]
       /* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */
       if (event === undefined) continue
-      const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null)
+      const node = materializeNode(
+        event,
+        this.callIdx,
+        this.resultViews.get(seq) ?? null,
+        event.type === 'assistant/message' ? this.assistantTiming(event) : undefined,
+      )
       this.nodeCache.set(seq, node)
       out.push(node)
     }
@@ -196,6 +203,33 @@ export class FoldAdapter {
     return seqs
   }
 
+  private assistantTiming(event: SessionEvent<'assistant/message'>): AssistantTiming {
+    let stepStartTime: number | null = null
+    let firstTokenTime: number | null = null
+    for (let i = this.baseSeq; i < this.padded.length; i++) {
+      const candidate = this.padded[i]
+      if (candidate === undefined || candidate.seq > event.seq) break
+      if (
+        candidate.type === 'step/start'
+        && candidate.data.turn === event.data.turn
+        && candidate.data.step === event.data.step
+      ) {
+        stepStartTime = candidate.time
+        continue
+      }
+      if (
+        firstTokenTime === null
+        && candidate.type === 'assistant/chunk'
+        && candidate.data.turn === event.data.turn
+        && candidate.data.step === event.data.step
+        && isTokenDelta(candidate.data.chunk)
+      ) {
+        firstTokenTime = candidate.time
+      }
+    }
+    return { stepStartTime, firstTokenTime, completedTime: event.time }
+  }
+
   private indexCall(event: SessionEvent, view?: ToolEventView): void {
     if (event.type === 'tool/result') {
       if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
@@ -211,3 +245,15 @@ export class FoldAdapter {
     // (window order puts the call before its result; cannot happen on the normal path).
   }
 }
+
+function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
+  switch (chunk.type) {
+    case 'text-delta':
+    case 'reasoning-delta':
+      return chunk.text !== ''
+    case 'tool-call-delta':
+      return chunk.argumentsDelta !== '' || chunk.name !== undefined
+    default:
+      return false
+  }
+}

+ 32 - 1
packages/client/runtime/src/client/sessions/session.ts

@@ -1,7 +1,7 @@
 // Sessions remain resident after creation so they continue consuming mux frames off-screen.
 
 import type { Context } from 'cordis'
-import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type {
   HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
@@ -104,6 +104,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
   private codeDispatches = new Map<string, readonly CodeSubCall[]>()
   private dispatchesRev = 0
   private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
+  /** Schemas in force for the next tool/call, updated by request/header. */
+  private activeToolSchemas = new Map<string, ToolSchema>()
+  /** Call-time schema snapshots keyed by native or code-dispatch call id. */
+  private callSchemas = new Map<string, ToolSchema>()
+  private callSchemasRev = 0
+  private callSchemasCache: { rev: number; value: ReadonlyMap<string, ToolSchema> } | null = null
   private running = false
   /**
    * Sticky send marker, private input of the composerPhase derivation: set
@@ -611,6 +617,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
         argsRaw: JSON.stringify(data.arguments),
         turn: 0, step: 0, time: event.time, callView: null,
       }
+      this.captureCallSchema(data.subCallId, data.name)
       const siblings = this.codeDispatches.get(data.parentCallId) ?? []
       this.codeDispatches.set(data.parentCallId, [...siblings, running])
       this.dispatchesRev++
@@ -630,6 +637,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
         content: ContentBlock[]
       }
       const siblings = this.codeDispatches.get(data.parentCallId) ?? []
+      this.captureCallSchema(data.subCallId, data.name)
       const at = siblings.findIndex(sub => sub.callId === data.subCallId)
       const started = at === -1 ? undefined : siblings[at]
       const settled: CodeSubCall = {
@@ -651,6 +659,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
       return
     }
     switch (event.type) {
+      case 'request/header': {
+        this.activeToolSchemas = new Map(
+          (event.data.header.tools ?? []).map(schema => [schema.name, schema]),
+        )
+        return
+      }
       case 'assistant/chunk': {
         const { turn, step, chunk } = event.data
         if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
@@ -666,6 +680,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
         return
       }
       case 'tool/call': {
+        this.captureCallSchema(String(event.data.callId), event.data.name)
         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, time: event.time,
@@ -720,6 +735,15 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     }
   }
 
+  /** Preserve the schema active when one call starts. */
+  private captureCallSchema(callId: string, name: string): void {
+    if (this.callSchemas.has(callId)) return
+    const schema = this.activeToolSchemas.get(name)
+    if (schema === undefined) return
+    this.callSchemas.set(callId, schema)
+    this.callSchemasRev++
+  }
+
   /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
    *  paging/stitching consistent, and makes the live freeze and the history replay converge on the
    *  same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
@@ -731,6 +755,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     this.frozenRev++
     this.codeDispatches = new Map()
     this.dispatchesRev++
+    this.activeToolSchemas = new Map()
+    this.callSchemas = new Map()
+    this.callSchemasRev++
     for (let i = 0; i < this.events.length; i++) {
       const event = this.events[i]
       /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
@@ -766,6 +793,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
       this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
     }
+    if (this.callSchemasCache === null || this.callSchemasCache.rev !== this.callSchemasRev) {
+      this.callSchemasCache = { rev: this.callSchemasRev, value: new Map(this.callSchemas) }
+    }
     if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
       this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
     }
@@ -778,6 +808,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
       runningCalls: this.callsCache.value,
       pending: this.pendingCache.value,
       codeDispatches: this.dispatchesCache.value,
+      callSchemas: this.callSchemasCache.value,
       queue: this.queueCache.value,
       running: this.running,
       composerPhase: derivePhase(

+ 4 - 4
packages/client/ui-conversation/src/client/apply.ts

@@ -72,8 +72,8 @@ export function apply(ctx: Context): void {
     },
   }), 'ui-conversation: input standard-kit provider')
 
-  // Resident current-session-optional shell. It owns the stable Hero/composer
-  // frame while strict session slots fill only their session-bound regions.
+  // Resident current-session-optional shell. It constructs the stable
+  // composer frame; the strict session child decides whether Chat mounts it.
   slots.register({
     name: 'conversation',
     children: {
@@ -106,8 +106,8 @@ export function apply(ctx: Context): void {
     }),
   }, ConversationRoot)
 
-  // The strict session subtree owns only per-session store and view content;
-  // the resident parent keeps Hero and composer layout identity stable.
+  // The strict session subtree owns the per-session store and view content;
+  // the resident parent supplies the composer node through owner props.
   slots.register({
     name: 'conversation.session',
     children: { 'conversation.view': { kind: 'list', scope: 'session' } },

+ 2 - 0
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -107,6 +107,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 
 /** Owner share of the strict session content seat. */
 export interface ConversationSessionOwnerProps {
+  /** Composer chain assembled by the resident parent; only Chat mounts it. */
+  composer: ReactNode
 }
 
 /**

+ 8 - 6
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx

@@ -74,6 +74,11 @@ export function ConversationRoot({
       {inputBar}
     </div>
   )
+  const composer = renderSlotChain(
+    'conversation.composer',
+    { interactions: pending },
+    { fallback: composerBar, overlay: true },
+  )
 
   return (
     <div className={css.root} data-phase={hero ? 'hero' : 'active'}>
@@ -81,12 +86,9 @@ export function ConversationRoot({
           renders no chrome while blank but owns the draft-persistence mirror
           bind — unmounting it in the hero would lose pre-first-send text on
           a refresh or scope rebuild. */}
-      {sessionId !== undefined && renderSlot('conversation.session', {})}
-      {renderSlotChain(
-        'conversation.composer',
-        { interactions: pending },
-        { fallback: composerBar, overlay: true },
-      )}
+      {sessionId !== undefined
+        ? renderSlot('conversation.session', { composer })
+        : composer}
     </div>
   )
 }

+ 8 - 7
packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx

@@ -1,6 +1,6 @@
 /** Strict per-session conversation content: header, view ring, and chat store bindings. */
 
-import { useEffect, useSyncExternalStore } from 'react'
+import { Fragment, useEffect, useSyncExternalStore } from 'react'
 import clsx from 'clsx'
 import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
@@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
 
 export function ConversationSession({
   sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
-  renderSlot, views, bindDraftMirror, open,
+  renderSlot, views, bindDraftMirror, open, composer,
 }: ConversationSessionProps) {
   useSyncExternalStore(views.subscribe, views.version)
   const tabs = views.list()
@@ -45,11 +45,11 @@ export function ConversationSession({
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [inputActions])
 
-  if (blank && composerPhase === 'blank') return null
+  const blankHero = blank && composerPhase === 'blank'
 
   return (
     <>
-      <header className={css.header}>
+      {!blankHero && <header className={css.header}>
         <div className={css.crumbRow}>
           <nav className={css.crumbs} aria-label="Session hierarchy">
             {ancestry.map((summary, index) => {
@@ -88,10 +88,11 @@ export function ConversationSession({
             ))}
           </div>
         )}
-      </header>
-      <div className={css.viewArea}>
+      </header>}
+      {!blankHero && <div className={css.viewArea}>
         {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
-      </div>
+      </div>}
+      {(blankHero || active?.id === 'chat') && <Fragment key="composer">{composer}</Fragment>}
     </>
   )
 }

+ 1 - 1
packages/client/ui-layout/src/client/AppFrame.module.css

@@ -54,12 +54,12 @@
    Handle component; the hit strip stays wider than the pill. */
 .handle {
   position: absolute;
+  z-index: 6;
   top: 0;
   bottom: 0;
   width: 8px;
   margin-left: -4px;
   cursor: col-resize;
-  z-index: 2;
   touch-action: none;
   /* Rides the same curve as the tracks so the pill stays on the moving
      border during collapse/expand; paused while dragging (frame rule). */

+ 4 - 0
packages/client/ui-primitives/package.json

@@ -22,8 +22,12 @@
   "dependencies": {
     "@shikijs/langs": "^4.3.1",
     "clsx": "^2.0.0",
+    "mdast-util-from-markdown": "^2.0.3",
+    "mdast-util-gfm": "^3.1.0",
+    "micromark-extension-gfm": "^3.0.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
+    "react-json-view-lite": "^2.5.0",
     "react-markdown": "^10.1.0",
     "remark-gfm": "^4.0.1",
     "shiki": "^4.3.1"

+ 221 - 0
packages/client/ui-primitives/src/JsonTree.module.css

@@ -0,0 +1,221 @@
+.root {
+  --json-tree-property: #881391;
+  --json-tree-string: #c41a16;
+  --json-tree-number: #1c00cf;
+  --json-tree-keyword: #1c00cf;
+  --json-tree-punctuation: #202124;
+  --json-tree-icon: #5f6368;
+  --json-tree-hover: rgb(60 64 67 / 4%);
+
+  min-width: 0;
+  overflow: auto;
+  position: relative;
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-bg-layer-1);
+  font: 12px/16px var(--ds-font-family-code);
+  overscroll-behavior: contain;
+}
+
+:global(body[data-ds-dark-theme]) .root {
+  --json-tree-property: #5db0d7;
+  --json-tree-string: #f28b82;
+  --json-tree-number: #99c8ff;
+  --json-tree-keyword: #99c8ff;
+  --json-tree-punctuation: #e8eaed;
+  --json-tree-icon: #9aa0a6;
+  --json-tree-hover: rgb(232 234 237 / 5%);
+}
+
+.container {
+  box-sizing: border-box;
+  width: max-content;
+  min-width: 100%;
+  margin: 0;
+  padding: 6px 8px 8px;
+  white-space: pre;
+}
+
+.expandedTopLevel {
+  box-sizing: border-box;
+  width: max-content;
+  min-width: 100%;
+  padding: 6px 8px 8px 14px;
+}
+
+.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover),
+.expandedTopLevel:has(> .topLevelBracket[data-json-root-row][data-json-copy-active]) {
+  background: var(--json-tree-hover);
+}
+
+.expandedTopLevelContainer {
+  padding: 0 0 0 calc(2ch - 12px);
+}
+
+.row.topLevelBracket {
+  margin-left: 0;
+  padding-left: 0;
+}
+
+.children {
+  margin: 0;
+  padding: 0 0 0 4px;
+  list-style: none;
+}
+
+.row {
+  position: relative;
+  box-sizing: border-box;
+  min-width: 100%;
+  min-height: 16px;
+  margin: 0;
+  padding: 0 0 0 12px;
+  list-style: none;
+}
+
+.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after,
+.row:not(.topLevelBracket)[data-json-copy-active]::after,
+.row:has(> .expander:focus-visible)::after {
+  position: absolute;
+  z-index: 0;
+  top: 0;
+  right: -100vw;
+  left: -100vw;
+  height: 16px;
+  background: var(--json-tree-hover);
+  content: '';
+  pointer-events: none;
+}
+
+.row > span:not(.expander) {
+  position: relative;
+  z-index: 1;
+}
+
+.label {
+  margin-right: 3px;
+  color: var(--json-tree-property);
+  font-weight: 400;
+}
+
+.clickableLabel {
+  cursor: pointer;
+}
+
+.stringValue {
+  color: var(--json-tree-string);
+}
+
+.numberValue {
+  color: var(--json-tree-number);
+}
+
+.keywordValue {
+  color: var(--json-tree-keyword);
+}
+
+.otherValue {
+  color: var(--dsw-alias-label-secondary);
+}
+
+.punctuation {
+  color: var(--json-tree-punctuation);
+}
+
+.preview {
+  color: var(--json-tree-punctuation);
+}
+
+.previewProperty {
+  color: var(--json-tree-punctuation);
+}
+
+.previewEllipsis {
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.copyAnchor {
+  position: absolute;
+  z-index: 3;
+  display: inline-flex;
+}
+
+.copyButton {
+  box-sizing: border-box;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 20px;
+  height: 16px;
+  margin: 0;
+  padding: 0;
+  border: 0;
+  border-radius: 3px;
+  color: var(--dsw-alias-label-secondary);
+  background: var(--dsw-alias-bg-layer-1);
+  box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1);
+  cursor: pointer;
+}
+
+.copyButton:hover {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.copyButton:focus-visible {
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: -1px;
+}
+
+.copyButton[data-state='failed'] {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.expander {
+  position: absolute;
+  z-index: 2;
+  top: 0;
+  left: 0;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+  width: 8px;
+  height: 16px;
+  margin: 0;
+  color: var(--json-tree-icon);
+  cursor: pointer;
+  user-select: none;
+}
+
+.expander::before {
+  width: 0;
+  height: 0;
+  border-top: 4px solid transparent;
+  border-bottom: 4px solid transparent;
+  border-left: 6px solid currentColor;
+  content: '';
+  transform: scale(0.75);
+  transform-origin: center;
+}
+
+.collapseIcon::before {
+  transform: rotate(90deg) scale(0.75);
+}
+
+.expander:hover {
+  color: var(--dsw-alias-label-primary);
+}
+
+.expander:focus-visible {
+  outline: none;
+}
+
+.collapsedContent {
+  margin: 0 1px;
+  color: var(--json-tree-punctuation);
+  cursor: pointer;
+}
+
+.collapsedContent::after {
+  content: '…';
+}

+ 390 - 0
packages/client/ui-primitives/src/JsonTree.tsx

@@ -0,0 +1,390 @@
+import clsx from 'clsx'
+import { collapseAllNested, JsonView } from 'react-json-view-lite'
+import { useEffect, useRef, useState } from 'react'
+import type { MouseEvent as ReactMouseEvent, ReactNode, UIEvent as ReactUIEvent } from 'react'
+import type { Props as LiteJsonViewProps } from 'react-json-view-lite'
+import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx'
+import { Menu } from './Menu.tsx'
+import type { MenuEntry } from './Menu.tsx'
+import css from './JsonTree.module.css'
+
+const OBJECT_PREVIEW_LIMIT = 4
+const ARRAY_PREVIEW_LIMIT = 5
+const PREVIEW_DEPTH_LIMIT = 2
+const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [
+  { id: 'value', label: 'Copy value' },
+  { id: 'json', label: 'Copy JSON' },
+  { id: 'path', label: 'Copy property path' },
+]
+const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
+  { id: 'prettyJson', label: 'Copy pretty JSON' },
+  { id: 'json', label: 'Copy compact JSON' },
+  { id: 'path', label: 'Copy property path' },
+]
+
+const TREE_STYLES: NonNullable<LiteJsonViewProps['style']> = {
+  container: css.container!,
+  childFieldsContainer: css.children!,
+  basicChildStyle: css.row!,
+  label: css.label!,
+  clickableLabel: `${css.label!} ${css.clickableLabel!}`,
+  nullValue: css.keywordValue!,
+  undefinedValue: css.keywordValue!,
+  numberValue: css.numberValue!,
+  stringValue: css.stringValue!,
+  booleanValue: css.keywordValue!,
+  otherValue: css.otherValue!,
+  punctuation: css.punctuation!,
+  expandIcon: `${css.expander!} ${css.expandIcon!}`,
+  collapseIcon: `${css.expander!} ${css.collapseIcon!}`,
+  collapsedContent: css.collapsedContent!,
+  noQuotesForStringValues: false,
+  quotesForFieldNames: false,
+  stringifyStringValues: true,
+  ariaLables: {
+    collapseJson: 'Collapse JSON node',
+    expandJson: 'Expand JSON node',
+  },
+}
+
+const EXPANDED_TOP_LEVEL_TREE_STYLES: NonNullable<LiteJsonViewProps['style']> = {
+  ...TREE_STYLES,
+  container: `${css.container!} ${css.expandedTopLevelContainer!}`,
+}
+
+function previewPrimitive(value: unknown): ReactNode {
+  if (value === null) return <span className={css.keywordValue}>null</span>
+  if (typeof value === 'string') {
+    return <span className={css.stringValue}>{JSON.stringify(value)}</span>
+  }
+  if (typeof value === 'number') {
+    return <span className={css.numberValue}>{String(value)}</span>
+  }
+  if (typeof value === 'boolean') {
+    return <span className={css.keywordValue}>{String(value)}</span>
+  }
+  return <span className={css.otherValue}>{String(value)}</span>
+}
+
+function previewValue(value: unknown, depth: number): ReactNode {
+  if (typeof value !== 'object' || value === null) return previewPrimitive(value)
+
+  const array = Array.isArray(value)
+  const entries = array
+    ? value.map((item, index) => [String(index), item] as const)
+    : Object.entries(value)
+  const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT
+  const visible = entries.slice(0, limit)
+  const open = array ? '[' : '{'
+  const close = array ? ']' : '}'
+
+  return (
+    <>
+      <span className={css.punctuation}>{open}</span>
+      {depth >= PREVIEW_DEPTH_LIMIT
+        ? <span className={css.previewEllipsis}>…</span>
+        : visible.map(([key, item], index) => (
+            <span key={key}>
+              {index > 0 && <span className={css.punctuation}>, </span>}
+              {!array && (
+                <>
+                  <span className={css.previewProperty}>{key}</span>
+                  <span className={css.punctuation}>: </span>
+                </>
+              )}
+              {previewValue(item, depth + 1)}
+            </span>
+          ))}
+      {depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && (
+        <span className={css.previewEllipsis}>{visible.length > 0 ? ', …' : '…'}</span>
+      )}
+      <span className={css.punctuation}>{close}</span>
+    </>
+  )
+}
+
+function renderExpandableValue(value: object): ReactNode {
+  return <span className={css.preview}>{previewValue(value, 0)}</span>
+}
+
+interface CopyTarget {
+  left: number
+  path: readonly (number | string)[]
+  side: 'bottom' | 'top'
+  top: number
+  value: unknown
+}
+
+function fieldOf(row: HTMLElement): string | undefined {
+  const label = Array.from(row.children).find(
+    child => child instanceof HTMLElement && child.classList.contains(css.label!),
+  )
+  const text = label?.textContent
+  return text === undefined || text === null ? undefined : text.slice(0, -1)
+}
+
+function resolveRow(data: object | unknown[], row: HTMLElement, expandTopLevel: boolean): {
+  path: readonly (number | string)[]
+  value: unknown
+} | undefined {
+  if (row.hasAttribute('data-json-root-row')) return { path: [], value: data }
+
+  const lineage: HTMLElement[] = []
+  let cursor: HTMLElement | null = row
+  while (cursor !== null) {
+    lineage.unshift(cursor)
+    const group: HTMLElement | null = cursor.parentElement
+    const parentRow: Element | null = group?.getAttribute('role') === 'group'
+      ? group.parentElement?.closest('[role="treeitem"]') ?? null
+      : null
+    cursor = parentRow instanceof HTMLElement ? parentRow : null
+  }
+
+  let value: unknown = data
+  const path: (number | string)[] = []
+  for (const item of expandTopLevel ? lineage : lineage.slice(1)) {
+    const field = fieldOf(item)
+    if (field === undefined) return undefined
+    if (Array.isArray(value)) {
+      const index = Number(field)
+      if (!Number.isInteger(index)) return undefined
+      path.push(index)
+      value = value[index]
+    } else if (typeof value === 'object' && value !== null) {
+      path.push(field)
+      value = (value as Record<string, unknown>)[field]
+    } else {
+      return undefined
+    }
+  }
+  return { path, value }
+}
+
+function formattedPath(path: readonly (number | string)[]): string {
+  return path.reduce<string>((result, part) => {
+    if (typeof part === 'number') return `${result}[${String(part)}]`
+    return /^[A-Za-z_$][\w$]*$/.test(part)
+      ? `${result}.${part}`
+      : `${result}[${JSON.stringify(part)}]`
+  }, '$')
+}
+
+function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
+  if (mode === 'path') return formattedPath(target.path)
+  if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
+  if (mode === 'json') return JSON.stringify(target.value) ?? String(target.value)
+  if (typeof target.value === 'string') return target.value
+  if (typeof target.value === 'object' && target.value !== null) {
+    return JSON.stringify(target.value, null, 2)
+  }
+  return JSON.stringify(target.value) ?? String(target.value)
+}
+
+/** Props for the read-only, token-themed JSON tree. */
+export interface JsonTreeProps {
+  /** Parsed JSON object or array. */
+  data: object | unknown[]
+  /** Accessible label for the tree. */
+  label?: string
+  /** Optional positioning class owned by the caller. */
+  className?: string | undefined
+  /** Whether JSON rows expose copy actions. */
+  copyable?: boolean
+  /** Whether the top-level object or array is always expanded. */
+  expandTopLevel?: boolean
+}
+
+/**
+ * Render parsed JSON as a compact, keyboard-accessible inspector tree.
+ * @param props - Parsed data, accessible label, and display options.
+ * @returns A read-only JSON tree with an optionally fixed-open top level.
+ */
+export function JsonTree({
+  data,
+  label = 'JSON',
+  className,
+  copyable = true,
+  expandTopLevel = true,
+}: JsonTreeProps) {
+  const rootRef = useRef<HTMLDivElement>(null)
+  const activeRowRef = useRef<HTMLElement>()
+  const copyButtonRef = useRef<HTMLButtonElement>(null)
+  const copyMenuOpenRef = useRef(false)
+  const resetTimer = useRef<ReturnType<typeof setTimeout>>()
+  const [copyTarget, setCopyTarget] = useState<CopyTarget>()
+  const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
+  const [copyMenuOpen, setCopyMenuOpen] = useState(false)
+
+  useEffect(() => () => {
+    if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
+    activeRowRef.current?.removeAttribute('data-json-copy-active')
+  }, [])
+
+  const setActiveRow = (row: HTMLElement | undefined) => {
+    activeRowRef.current?.removeAttribute('data-json-copy-active')
+    activeRowRef.current = row
+    row?.setAttribute('data-json-copy-active', '')
+  }
+
+  const positionCopyButton = (row: HTMLElement, target: {
+    path: readonly (number | string)[]
+    value: unknown
+  }) => {
+    const root = rootRef.current
+    if (root === null) return
+    const rootRect = root.getBoundingClientRect()
+    const rowRect = row.getBoundingClientRect()
+    setCopyTarget({
+      left: root.scrollLeft + root.clientWidth - 26,
+      path: target.path,
+      side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
+      top: root.scrollTop + rowRect.top - rootRect.top,
+      value: target.value,
+    })
+  }
+
+  const clearCopyTarget = () => {
+    setActiveRow(undefined)
+    setCopyTarget(undefined)
+    setCopyState('idle')
+    copyMenuOpenRef.current = false
+    setCopyMenuOpen(false)
+  }
+
+  const handleMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
+    if (!copyable || !(event.target instanceof Element)) return
+    if (copyMenuOpenRef.current) return
+    if (!event.currentTarget.contains(event.target)) return
+    if (event.target.closest('[data-json-copy-button]') !== null) return
+    const row = event.target.closest<HTMLElement>('[data-json-root-row], [role="treeitem"]')
+    if (row === null) {
+      clearCopyTarget()
+      return
+    }
+    if (activeRowRef.current === row) return
+    const resolved = resolveRow(data, row, expandTopLevel)
+    if (resolved === undefined) return
+    setActiveRow(row)
+    setCopyState('idle')
+    copyMenuOpenRef.current = false
+    setCopyMenuOpen(false)
+    positionCopyButton(row, resolved)
+  }
+
+  const handleScroll = (event: ReactUIEvent<HTMLDivElement>) => {
+    if (event.currentTarget !== event.target) return
+    const row = activeRowRef.current
+    if (row === undefined) return
+    const resolved = resolveRow(data, row, expandTopLevel)
+    if (resolved !== undefined) positionCopyButton(row, resolved)
+  }
+
+  const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => {
+    if (copyTarget === undefined) return
+    try {
+      await navigator.clipboard.writeText(copyText(copyTarget, mode))
+      setCopyState('copied')
+    } catch {
+      setCopyState('failed')
+    }
+    if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
+    resetTimer.current = setTimeout(() => setCopyState('idle'), 1_500)
+  }
+
+  const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
+  const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
+  const copyTitle = copyState === 'copied'
+    ? 'Copied'
+    : copyState === 'failed'
+      ? 'Copy failed'
+      : copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value'
+
+  return (
+    <div
+      ref={rootRef}
+      className={clsx(css.root, className)}
+      onMouseOver={handleMouseOver}
+      onMouseLeave={() => {
+        if (!copyMenuOpenRef.current) clearCopyTarget()
+      }}
+      onScroll={handleScroll}
+    >
+      {expandTopLevel
+        ? (
+            <div className={css.expandedTopLevel}>
+              <div className={clsx(css.row, css.topLevelBracket)} data-json-root-row>
+                <span className={css.punctuation}>{Array.isArray(data) ? '[' : '{'}</span>
+              </div>
+              <JsonView
+                aria-label={label}
+                compactTopLevel
+                data={data}
+                style={EXPANDED_TOP_LEVEL_TREE_STYLES}
+                shouldExpandNode={collapseAllNested}
+                clickToExpandNode
+                renderExpandableValue={renderExpandableValue}
+              />
+              <div className={clsx(css.row, css.topLevelBracket)}>
+                <span className={css.punctuation}>{Array.isArray(data) ? ']' : '}'}</span>
+              </div>
+            </div>
+          )
+        : (
+            <JsonView
+              aria-label={label}
+              data={data}
+              style={TREE_STYLES}
+              shouldExpandNode={collapseAllNested}
+              clickToExpandNode
+              renderExpandableValue={renderExpandableValue}
+            />
+          )}
+      {copyTarget !== undefined && (
+        <span
+          className={css.copyAnchor}
+          style={{ left: copyTarget.left, top: copyTarget.top }}
+        >
+          <Menu
+            open={copyMenuOpen}
+            compact
+            portal
+            align="end"
+            side={copyTarget.side}
+            anchor={(
+              <button
+                ref={copyButtonRef}
+                type="button"
+                className={css.copyButton}
+                data-json-copy-button
+                data-state={copyState}
+                aria-label={copyTitle}
+                title={`${copyTitle}; right-click for copy options`}
+                onClick={() => void copy(defaultCopyMode)}
+                onContextMenu={(event) => {
+                  event.preventDefault()
+                  event.stopPropagation()
+                  copyMenuOpenRef.current = true
+                  setCopyMenuOpen(true)
+                }}
+              >
+                {copyState === 'copied'
+                  ? <IconCheckOutline16 size={12} />
+                  : <IconCopyOutline16 size={12} />}
+              </button>
+            )}
+            items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
+            onSelect={(id) => {
+              if (id === 'value' || id === 'json' || id === 'prettyJson' || id === 'path') {
+                void copy(id)
+              }
+              copyMenuOpenRef.current = false
+              setCopyMenuOpen(false)
+            }}
+            onClose={clearCopyTarget}
+            getAnchorRect={() => copyButtonRef.current?.getBoundingClientRect() ?? null}
+          />
+        </span>
+      )}
+    </div>
+  )
+}

+ 31 - 0
packages/client/ui-primitives/src/Menu.module.css

@@ -77,6 +77,37 @@
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
+.list.compactList,
+.submenu.compactList {
+  min-width: 164px;
+  padding: 2px;
+  border-radius: 7px;
+}
+
+.compactList .item {
+  min-height: 26px;
+  gap: 6px;
+  padding: 3px 7px;
+  border-radius: 5px;
+  font-size: 12px;
+  line-height: 18px;
+}
+
+.compactList .itemIcon {
+  width: 14px;
+  height: 14px;
+}
+
+.compactList .separator {
+  margin: 2px;
+}
+
+.compactList .label {
+  padding: 4px 7px;
+  font-size: 11px;
+  line-height: 16px;
+}
+
 .item:disabled {
   color: var(--dsw-alias-label-dimmed);
   cursor: not-allowed;

+ 5 - 3
packages/client/ui-primitives/src/Menu.tsx

@@ -66,6 +66,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
  * keeps the pure-CSS in-place behavior.
  * @param props.closeOnPointerLeave - close the list when the pointer leaves
  * it (default false keeps it open until outside click/Escape/selection).
+ * @param props.compact - use reduced menu typography and spacing.
  * @param props.getAnchorRect - portal mode only: supply the anchor rect
  * directly (e.g. from a host-owned trigger button) instead of measuring the
  * Menu's own wrapper span. Required when the wrapper isn't itself laid out at
@@ -74,7 +75,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
  * scroll/resize; return null to skip placement for that frame.
  * @returns anchor wrapper with the conditional list.
  */
-export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
+export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, className }: {
   open: boolean
   anchor: ReactNode
   items: readonly MenuEntry[]
@@ -85,6 +86,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
   side?: 'bottom' | 'top'
   portal?: boolean
   closeOnPointerLeave?: boolean
+  compact?: boolean
   getAnchorRect?: () => DOMRect | null
   className?: string
 }) {
@@ -149,7 +151,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
   const list = open && (!portal || fixedPos !== null) && (
     <div
       ref={listRef}
-      className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
+      className={clsx(css.list, compact && css.compactList, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
       style={fixedPos ?? undefined}
       role="menu"
       onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
@@ -196,7 +198,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
                   {entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
                 </button>
                 {subOpen && entry.submenu !== undefined && (
-                  <div className={css.submenu} role="menu">
+                  <div className={clsx(css.submenu, compact && css.compactList)} role="menu">
                     {entry.submenu.map(sub => (
                       <button
                         key={sub.id}

+ 4 - 0
packages/client/ui-primitives/src/index.ts

@@ -17,8 +17,12 @@ export { FishLogo } from './FishLogo.tsx'
 export { BrandWordmark } from './BrandWordmark.tsx'
 export { Tooltip } from './Tooltip.tsx'
 export type { TooltipSide } from './Tooltip.tsx'
+export { JsonTree } from './JsonTree.tsx'
+export type { JsonTreeProps } from './JsonTree.tsx'
 export { CodeBlock } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'
 export { MessageText } from './markdown/MessageText.tsx'
+export { extractMarkdownPlainText } from './markdown/plain-text.ts'
+export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
 export * from './icons/index.tsx'

+ 126 - 0
packages/client/ui-primitives/src/markdown/plain-text.ts

@@ -0,0 +1,126 @@
+/**
+ * Markdown-to-plain-text projection for compact summaries and labels.
+ * Parsing shares the renderer's GFM grammar; raw HTML is intentionally
+ * omitted, links keep their labels, images keep alt text, and code keeps its
+ * source text.
+ */
+
+import { fromMarkdown } from 'mdast-util-from-markdown'
+import { gfmFromMarkdown } from 'mdast-util-gfm'
+import { gfm } from 'micromark-extension-gfm'
+
+/** Amount of parsed Markdown content returned by the extractor. */
+export type MarkdownPlainTextMode = 'all' | 'first-line' | 'first-paragraph'
+
+/** Options for {@link extractMarkdownPlainText}. */
+export interface MarkdownPlainTextOptions {
+  /** Projection boundary; defaults to the complete document. */
+  mode?: MarkdownPlainTextMode
+}
+
+interface MarkdownNode {
+  type: string
+  value?: string
+  alt?: string
+  children?: MarkdownNode[]
+}
+
+function inlineText(node: MarkdownNode): string {
+  switch (node.type) {
+    case 'text':
+    case 'inlineCode':
+    case 'code':
+      return node.value ?? ''
+    case 'image':
+    case 'imageReference':
+      return node.alt ?? ''
+    case 'break':
+      return '\n'
+    case 'html':
+    case 'thematicBreak':
+    case 'definition':
+      return ''
+    default:
+      return node.children?.map(inlineText).join('') ?? ''
+  }
+}
+
+function compactInline(text: string): string {
+  return text.replace(/\s+/g, ' ').trim()
+}
+
+function blockText(node: MarkdownNode): string {
+  switch (node.type) {
+    case 'root':
+    case 'blockquote':
+      return node.children?.map(blockText).filter(Boolean).join('\n\n') ?? ''
+    case 'paragraph':
+    case 'heading':
+      return compactInline(inlineText(node))
+    case 'code':
+      return node.value?.trim() ?? ''
+    case 'list':
+      return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
+    case 'listItem':
+      return node.children?.map(blockText).filter(Boolean).join(' ') ?? ''
+    case 'table':
+      return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
+    case 'tableRow':
+      return node.children?.map(blockText).join('\t') ?? ''
+    case 'tableCell':
+      return compactInline(inlineText(node))
+    case 'html':
+    case 'thematicBreak':
+    case 'definition':
+      return ''
+    default:
+      return compactInline(inlineText(node))
+  }
+}
+
+function findFirstParagraph(node: MarkdownNode): string | undefined {
+  if (node.type === 'paragraph') {
+    const text = compactInline(inlineText(node))
+    if (text !== '') return text
+  }
+  for (const child of node.children ?? []) {
+    const text = findFirstParagraph(child)
+    if (text !== undefined) return text
+  }
+  return undefined
+}
+
+function fullText(root: MarkdownNode): string {
+  return blockText(root)
+    .split('\n')
+    .map(line => line.trim())
+    .join('\n')
+    .replace(/\n{3,}/g, '\n\n')
+    .trim()
+}
+
+/**
+ * Parse GFM Markdown and project its user-visible plain text.
+ * @param markdown - Markdown source.
+ * @param options - Optional extraction boundary.
+ * @returns Plain text for the whole document, first visible line, or first semantic paragraph.
+ */
+export function extractMarkdownPlainText(
+  markdown: string,
+  options: MarkdownPlainTextOptions = {},
+): string {
+  const { mode = 'all' } = options
+  const root = fromMarkdown(markdown, {
+    extensions: [gfm()],
+    mdastExtensions: [gfmFromMarkdown()],
+  }) as MarkdownNode
+  const all = fullText(root)
+  switch (mode) {
+    case 'all':
+      return all
+    case 'first-line':
+      return all.split('\n').find(line => line !== '') ?? ''
+    case 'first-paragraph':
+      return findFirstParagraph(root) ?? all.split('\n').find(line => line !== '') ?? ''
+  }
+}

+ 42 - 0
packages/client/ui-primitives/tests/markdown-plain-text.spec.ts

@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
+
+const MARKDOWN = [
+  '# Release notes',
+  '',
+  'First **paragraph** with [a link](https://example.com) and ![diagram](diagram.png).',
+  '',
+  '- shipped',
+  '- `verified`',
+  '',
+  '```ts',
+  'const ready = true',
+  '```',
+].join('\n')
+
+describe('extractMarkdownPlainText', () => {
+  it('projects the complete GFM document without presentation syntax', () => {
+    expect(extractMarkdownPlainText(MARKDOWN)).toBe([
+      'Release notes',
+      '',
+      'First paragraph with a link and diagram.',
+      '',
+      'shipped',
+      'verified',
+      '',
+      'const ready = true',
+    ].join('\n'))
+  })
+
+  it('selects the first visible line or first semantic paragraph', () => {
+    expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-line' })).toBe('Release notes')
+    expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-paragraph' }))
+      .toBe('First paragraph with a link and diagram.')
+  })
+
+  it('omits raw HTML and returns a useful fallback when no paragraph exists', () => {
+    const markdown = '<script>alert(1)</script>\n\n## Safe heading'
+    expect(extractMarkdownPlainText(markdown)).toBe('Safe heading')
+    expect(extractMarkdownPlainText(markdown, { mode: 'first-paragraph' })).toBe('Safe heading')
+  })
+})

+ 3 - 3
packages/client/ui-trajectory/README.i18n.yaml

@@ -1,6 +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
-README.md: 9e7dee8d5572baad13d7598d1fbde7b042dd5ab4
-README.zh.md: da14d5265c16acb4e75d21bc445ef9702c83e697
+#   pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
+README.md: 884db29b746124ecc1d7a8d2312101b2930c113f
+README.zh.md: a4d7d4513317692798bb6ea8b453293fa05b6cbe

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

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-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.
+Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. The package also provides the Waterfall view and remains a pure-consumer plugin (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
 
@@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request.
 
 ## Known Limitations and Deferred Work
 
-- **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.
+- **In-flight Time stays blank** — `partial` / `runningCalls` rows show their running state without a fabricated duration until a live clock policy lands; record selection is intentionally local to Trajectory; anchor deep-linking remains deferred.

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

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-轨迹轮次列表 chrome(吸顶 Turn/Message·Step 分组/步骤单元格)及 Waterfall 占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
+Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。该包(package)还提供 waterfall(瀑布式事件)视图,并保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
 
 ## 模型体验
 
@@ -14,4 +14,4 @@
 
 ## 已知限制与暂缓事项
 
-- **进行中的 Time 保持空白**:`partial`/`runningCalls` 行在实时钟策略落地前渲染为 `—`;选中样式只在本地生效(未连接到聊天详情);锚点深链接仍暂缓实现。
+- **进行中的 Time 保持空白**:`partial`/`runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时;记录选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。

+ 3 - 1
packages/client/ui-trajectory/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-trajectory",
-  "description": "Trajectory/Waterfall placeholder views: pure-consumer plugin registering into the conversation ViewMap (no service)",
+  "description": "Trajectory event-ledger and Waterfall views: pure-consumer plugin registering into the conversation ViewMap (no service)",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -35,11 +35,13 @@
   "license": "BSD-3-Clause",
   "peerDependencies": {
     "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
     "cordis": "^4.0.0-rc.7",
     "react": "^18.2.0"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",

+ 23 - 40
packages/client/ui-trajectory/src/client/TrajectoryCell.tsx

@@ -1,12 +1,18 @@
-// TrajectoryCell: one step row in the trajectory list — index, kind tag,
-// ellipsis text, optional Message token metrics, and own-duration time.
+// Legacy standalone trajectory cell retained for direct consumers and specs.
 
-import type { HTMLAttributes } from 'react'
+import {
+  formatElapsedSeconds,
+  type TrajectoryCellKind,
+  type TrajectoryCellProps,
+} from './trajectory-record.ts'
 import css from './TrajectoryCell.module.css'
 
-/** 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'
+export { formatElapsedSeconds }
+export type {
+  AssistantMetricDetail,
+  TrajectoryCellKind,
+  TrajectoryCellProps,
+} from './trajectory-record.ts'
 
 /** Display label per kind (matches the design tags). */
 const KIND_LABEL: Record<TrajectoryCellKind, string> = {
@@ -23,40 +29,6 @@ const TAG_CLASS: Record<TrajectoryCellKind, string> = {
   subtool: css.tagSubtool!,
 }
 
-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.
@@ -66,7 +38,18 @@ export function TrajectoryCell({
   index,
   kind,
   text,
+  inputDetail: _inputDetail,
+  outputDetail: _outputDetail,
+  thinkingDetail: _thinkingDetail,
+  sourceBlocks: _sourceBlocks,
+  outputBlocks: _outputBlocks,
+  schemaDetail: _schemaDetail,
+  assistantMetrics: _assistantMetrics,
+  result: _result,
+  callId: _callId,
+  isError: _isError,
   timeSeconds,
+  startedAt: _startedAt,
   input,
   output,
   think,

+ 1067 - 0
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css

@@ -0,0 +1,1067 @@
+.split {
+  position: relative;
+  display: flex;
+  flex: 1;
+  width: 100%;
+  min-height: 0;
+  overflow: hidden;
+  background: var(--dsw-alias-bg-layer-1);
+  container-type: inline-size;
+}
+
+.tablePane {
+  flex: 1;
+  min-width: 0;
+  overflow: auto;
+}
+
+.table {
+  --trajectory-turn-accent: color-mix(
+    in srgb,
+    var(--dsw-alias-brand-primary-new-colorprimary-new-color) 22%,
+    var(--dsw-alias-bg-layer-1)
+  );
+
+  width: 100%;
+  min-width: 480px;
+  border-spacing: 0;
+  table-layout: fixed;
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-bg-layer-1);
+  font: var(--dsw-font-xs-13);
+}
+
+.eventColumn {
+  width: 96px;
+}
+
+.contentColumn {
+  width: auto;
+}
+
+.table th {
+  position: sticky;
+  top: 0;
+  z-index: 3;
+  box-sizing: border-box;
+  height: 30px;
+  padding: 0 8px;
+  overflow: hidden;
+  border-bottom: 1px solid var(--dsw-alias-border-l2);
+  color: var(--dsw-alias-label-tertiary);
+  background: var(--dsw-specific-sidebar-fill);
+  font: var(--dsw-font-xs-13);
+  font-weight: 500;
+  text-align: left;
+  text-overflow: ellipsis;
+  user-select: none;
+  white-space: nowrap;
+}
+
+.eventHeader {
+  padding-right: 4px !important;
+  text-align: right !important;
+}
+
+.table td {
+  box-sizing: border-box;
+  height: 30px;
+  padding: 0 8px;
+  overflow: hidden;
+  border-bottom: 1px solid var(--dsw-alias-border-l1);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.table tbody tr:not([data-collapsed-summary]) {
+  cursor: default;
+  outline: none;
+  transition: background-color 120ms var(--ds-ease-in-out);
+}
+
+.table tbody tr:not([data-collapsed-summary]):hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.table tbody tr:not([data-collapsed-summary]):focus-visible {
+  box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary);
+}
+
+.table tbody tr[data-selected='true'] {
+  background: var(--dsw-alias-interactive-bg-active);
+}
+
+.event {
+  position: relative;
+}
+
+.turnRail,
+.selectionRail {
+  position: absolute;
+  left: 0;
+  background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
+  pointer-events: none;
+}
+
+.turnRail {
+  z-index: 4;
+  top: -1px;
+  bottom: -1px;
+  width: 2px;
+  background: var(--trajectory-turn-accent);
+}
+
+.table tbody tr[data-turn-end='true'] .turnRail {
+  bottom: 0;
+}
+
+.selectionRail {
+  z-index: 5;
+  top: 0;
+  bottom: 0;
+  width: 3px;
+}
+
+.table tbody tr[data-turn-start='true'] td {
+  position: relative;
+  overflow: visible;
+}
+
+.table tbody tr[data-turn-start='true']:not(:first-child) td::before {
+  position: absolute;
+  z-index: 1;
+  top: 0;
+  right: 0;
+  left: 0;
+  height: 2px;
+  background: var(--dsw-alias-border-l1);
+  content: '';
+  pointer-events: none;
+  transform: translateY(-50%);
+}
+
+.event {
+  overflow: visible !important;
+  padding-right: 4px !important;
+  padding-left: 10px !important;
+}
+
+.turnLabel {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  box-sizing: border-box;
+  width: max-content;
+  margin-left: 4px;
+  padding: 1px 2px;
+  border-radius: 2px;
+  color: var(--dsw-alias-label-tertiary);
+  background: var(--dsw-alias-bg-module-platform);
+  font: 8px/10px var(--ds-font-family-code);
+  font-variant-numeric: tabular-nums;
+  user-select: none;
+  white-space: nowrap;
+}
+
+.turnLabelActive {
+  color: color-mix(
+    in srgb,
+    var(--dsw-static-blue-500) 55%,
+    var(--dsw-alias-label-tertiary)
+  );
+  background: color-mix(
+    in srgb,
+    var(--dsw-static-blue-500) 10%,
+    var(--dsw-alias-bg-layer-1)
+  );
+}
+
+.eventInner {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  min-width: 0;
+  height: 100%;
+}
+
+.kindSlot {
+  display: flex;
+  flex: none;
+  align-items: flex-end;
+  width: 76px;
+}
+
+.kindSlotLeft {
+  justify-content: flex-start;
+}
+
+.kindSlotRight {
+  justify-content: flex-end;
+}
+
+.content {
+  padding-left: 4px !important;
+  color: var(--dsw-alias-label-primary);
+}
+
+.kindTag {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  box-sizing: border-box;
+  height: 19px;
+  padding: 0 5px;
+  border: 1px solid transparent;
+  border-radius: 4px;
+  font-size: 10px;
+  line-height: 16px;
+  font-weight: 650;
+  letter-spacing: 0.035em;
+  user-select: none;
+}
+
+.table .kindSlot .message {
+  justify-content: center;
+  width: 100%;
+}
+
+.user {
+  color: var(--dsw-alias-state-business-primary);
+  background: var(--dsw-alias-state-business-tertiary);
+}
+
+.message {
+  border-color: var(--dsw-alias-border-l2);
+  color: var(--dsw-alias-label-secondary);
+  background: var(--dsw-alias-bg-module-platform);
+}
+
+.tool {
+  border-color: var(--dsw-alias-border-l2);
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-bg-layer-1);
+}
+
+.subtool {
+  border-color: var(--dsw-alias-border-l2);
+  color: var(--dsw-alias-state-business-primary);
+  background: transparent;
+}
+
+.contentText {
+  display: block;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.table tbody tr[data-collapsed-summary] td {
+  height: 24px;
+}
+
+.table tbody tr[data-collapsed-summary] {
+  cursor: pointer;
+  outline: none;
+  transition: background-color 120ms var(--ds-ease-in-out);
+}
+
+.table tbody tr[data-collapsed-summary]:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.table tbody tr[data-collapsed-summary]:focus-visible {
+  box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary);
+}
+
+.collapsedTurnContent {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+  color: var(--dsw-alias-label-secondary);
+  font-size: 12px;
+  line-height: 18px;
+}
+
+.collapsedTurnEllipsis {
+  flex: none;
+  margin-right: 6px;
+  color: var(--dsw-alias-label-tertiary);
+  font-weight: 600;
+  user-select: none;
+}
+
+.collapsedTurnText {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.resultPreview {
+  display: grid;
+  grid-template-columns:
+    clamp(
+      180px,
+      var(--trajectory-tool-request-width, calc(36cqw - 56px)),
+      480px
+    )
+    minmax(0, 1fr);
+  align-items: center;
+  min-width: 0;
+  gap: 8px;
+}
+
+.resultRequest,
+.inlineResultText {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.table tbody tr[data-kind='tool'] .contentText,
+.table tbody tr[data-kind='subtool'] .contentText,
+.table tbody tr[data-kind='tool'] .resultPreview,
+.table tbody tr[data-kind='subtool'] .resultPreview {
+  font-family: var(--ds-font-family-code);
+  font-size: 12px;
+}
+
+.table tbody tr[data-kind='subtool'] .content {
+  padding-left: 26px;
+}
+
+.inlineResult {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.arrow {
+  flex: none;
+  margin-right: 8px;
+  color: var(--dsw-alias-label-caption);
+}
+
+.error {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.details {
+  position: relative;
+  display: flex;
+  flex: none;
+  flex-direction: column;
+  width: clamp(320px, 38%, 440px);
+  max-width: calc(100% - 280px);
+  min-width: 0;
+  min-height: 0;
+  border-left: 1px solid var(--dsw-alias-border-l2);
+  background: var(--dsw-alias-bg-layer-1);
+}
+
+.detailsResizeHandle {
+  position: absolute;
+  z-index: 6;
+  top: 0;
+  bottom: 0;
+  left: -4px;
+  width: 8px;
+  padding: 0;
+  border: 0;
+  background: transparent;
+  cursor: col-resize;
+  touch-action: none;
+  user-select: none;
+}
+
+.detailsResizeHandle::after {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  box-sizing: border-box;
+  width: 12px;
+  height: 32px;
+  border: 1px solid var(--dsw-alias-border-l2);
+  border-radius: 10px;
+  background: var(--dsw-alias-bg-layer-1);
+  content: '';
+  opacity: 0;
+  transform: translate(-50%, -50%);
+  transition:
+    opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
+    background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
+}
+
+.details:hover .detailsResizeHandle::after,
+.detailsResizeHandle:hover::after,
+.detailsResizeHandle:active::after,
+.detailsResizeHandle:focus-visible::after {
+  opacity: 1;
+}
+
+.detailsResizeHandle:hover::after,
+.detailsResizeHandle:active::after,
+.detailsResizeHandle:focus-visible::after {
+  border-color: var(--dsw-alias-border-l3);
+  background: var(--dsw-alias-bg-module-platform);
+}
+
+.detailsResizeHandle:focus-visible {
+  outline: none;
+}
+
+.detailsHeader {
+  display: flex;
+  flex: none;
+  align-items: center;
+  justify-content: space-between;
+  box-sizing: border-box;
+  height: 42px;
+  padding: 0 8px 0 12px;
+  border-bottom: 1px solid var(--dsw-alias-border-l2);
+}
+
+.detailsTitle {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+  gap: 8px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.detailsLocation {
+  min-width: 0;
+  overflow: hidden;
+  color: var(--dsw-alias-label-tertiary);
+  font: 11px/16px var(--ds-font-family-code);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.close {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  padding: 0;
+  border: 0;
+  border-radius: 6px;
+  color: var(--dsw-alias-label-secondary);
+  background: transparent;
+  cursor: pointer;
+  font-size: 18px;
+  line-height: 18px;
+}
+
+.close:hover {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.detailTabs {
+  display: flex;
+  flex: none;
+  height: 34px;
+  padding: 0 8px;
+  overflow-x: auto;
+  gap: 2px;
+  border-bottom: 1px solid var(--dsw-alias-border-l2);
+  scrollbar-width: none;
+}
+
+.detailTab {
+  position: relative;
+  flex: none;
+  padding: 0 9px;
+  border: 0;
+  color: var(--dsw-alias-label-tertiary);
+  background: transparent;
+  cursor: pointer;
+  font: var(--dsw-font-xs-13);
+}
+
+.detailTab:hover {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.detailTabActive {
+  color: var(--dsw-alias-state-business-primary);
+}
+
+.detailTabActive::after {
+  position: absolute;
+  right: 9px;
+  bottom: 0;
+  left: 9px;
+  height: 2px;
+  border-radius: 1px 1px 0 0;
+  background: var(--dsw-alias-state-business-primary);
+  content: '';
+}
+
+.close:focus-visible,
+.detailTab:focus-visible {
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: -1px;
+}
+
+.detailBody {
+  flex: 1;
+  min-height: 0;
+  overflow: auto;
+}
+
+.overview {
+  margin: 0;
+  padding: 8px 0;
+  font: var(--dsw-font-xs-13);
+}
+
+.overviewParentLinks {
+  display: flex;
+  gap: 14px;
+}
+
+.overviewHierarchyLink {
+  all: unset;
+  display: inline-flex;
+  align-items: center;
+  color: var(--dsw-alias-label-primary);
+  cursor: pointer;
+  font: inherit;
+  opacity: 1;
+  text-decoration-line: underline;
+  text-decoration-color: currentColor;
+  text-decoration-style: solid;
+  text-decoration-thickness: 1px;
+  text-underline-offset: 2px;
+}
+
+.overviewHierarchyLink:hover {
+  color: var(--dsw-alias-label-primary);
+  text-decoration-thickness: 2px;
+}
+
+.overviewHierarchyLink:focus-visible {
+  color: var(--dsw-alias-label-primary);
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: 2px;
+}
+
+.overview > div {
+  display: grid;
+  grid-template-columns: 94px minmax(0, 1fr);
+  min-height: 22px;
+  padding: 0 14px;
+  align-items: center;
+}
+
+.overview dt {
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.overview dd {
+  min-width: 0;
+  margin: 0;
+  overflow: hidden;
+  color: var(--dsw-alias-label-primary);
+  font-variant-numeric: tabular-nums;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.overviewSections {
+  border-top: 0;
+}
+
+.overviewSection + .overviewSection {
+  padding-top: 8px;
+}
+
+.overviewHeading {
+  display: flex;
+  align-items: flex-end;
+  box-sizing: border-box;
+  width: 100%;
+  height: 28px;
+  margin: 0;
+  padding: 0 0 3px 14px;
+  color: var(--dsw-alias-label-secondary);
+  background: var(--dsw-alias-bg-base);
+  font: var(--dsw-font-xs-strong-13);
+  user-select: none;
+}
+
+.overviewTitle {
+  display: inline-flex;
+  flex: none;
+  width: max-content;
+  height: auto;
+  align-items: center;
+  padding: 0;
+  border: 0;
+  color: inherit;
+  background: transparent;
+  cursor: pointer;
+  font: inherit;
+  gap: 3px;
+  line-height: 1;
+  user-select: none;
+}
+
+.overviewTitleIcon {
+  flex: none;
+  color: var(--dsw-alias-label-caption);
+}
+
+.overviewTitle:hover .overviewTitleIcon {
+  color: var(--dsw-alias-label-primary);
+}
+
+.overviewTitle:focus-visible {
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: 2px;
+}
+
+.overviewTitle:focus-visible .overviewTitleIcon {
+  color: var(--dsw-alias-state-business-primary);
+}
+
+.overviewPreview {
+  max-height: 180px;
+  overflow: auto;
+  overscroll-behavior: contain;
+  background: var(--dsw-alias-bg-layer-1);
+}
+
+.overviewPreview > :last-child {
+  margin-bottom: 0;
+  padding-bottom: 0;
+}
+
+.overviewPreview .jsonPreview > :last-child,
+.overviewPreview .schemaTree > :last-child {
+  padding-bottom: 0;
+}
+
+.overviewPreview .schemaTree {
+  margin-bottom: 0;
+}
+
+.overviewPreview .overview {
+  padding: 2px 0 0;
+}
+
+.overviewPreview .overview > div {
+  min-height: 22px;
+}
+
+.markdownPreview,
+.markdownPayload {
+  color: var(--dsw-alias-label-primary);
+}
+
+.markdownPreview > div,
+.markdownPayload > div {
+  gap: 8px;
+  font: var(--dsw-font-xs-13);
+}
+
+.markdownPreview > div h1,
+.markdownPayload > div h1 {
+  font: 600 16px/22px var(--dsw-font-family);
+}
+
+.markdownPreview > div h2,
+.markdownPayload > div h2 {
+  font: 600 15px/22px var(--dsw-font-family);
+}
+
+.markdownPreview > div :where(h3, h4, h5, h6),
+.markdownPayload > div :where(h3, h4, h5, h6) {
+  font: 600 14px/20px var(--dsw-font-family);
+}
+
+.markdownPreview > div :not(pre) > code,
+.markdownPayload > div :not(pre) > code {
+  font: 12px/18px var(--ds-font-family-code);
+}
+
+.markdownPreview > div table,
+.markdownPayload > div table {
+  font: var(--dsw-font-xs-13);
+}
+
+.assistantContent .markdownPayload,
+.assistantContent .payload {
+  min-height: 0;
+}
+
+.thinkingQuote {
+  margin: 6px 14px 0 12px;
+  padding-left: 6px;
+  border-left: 2px solid var(--dsw-alias-markdown-citation);
+  color: var(--dsw-alias-label-secondary);
+  background: transparent;
+}
+
+.thinkingQuoteOnlyPreview {
+  margin-bottom: 8px;
+}
+
+.detailBody > .assistantContentRendered .thinkingQuote {
+  margin-top: 14px;
+}
+
+.thinkingToggle {
+  display: flex;
+  align-items: center;
+  width: max-content;
+  height: 18px;
+  padding: 0;
+  border: 0;
+  color: var(--dsw-alias-label-tertiary);
+  background: transparent;
+  cursor: pointer;
+  font: 600 12px/18px var(--dsw-font-family);
+  user-select: none;
+}
+
+.thinkingToggle:hover {
+  color: var(--dsw-alias-label-secondary);
+}
+
+.thinkingToggle:focus-visible {
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: 2px;
+}
+
+.thinkingQuote .markdownPreview,
+.thinkingQuote .markdownPayload,
+.thinkingQuote .payload {
+  min-height: 0;
+  padding: 2px 0;
+  color: var(--dsw-alias-label-secondary);
+  background: transparent;
+}
+
+.assistantOutput {
+  background: var(--dsw-alias-bg-layer-1);
+}
+
+.markdownPreview {
+  padding: 6px 14px 8px;
+}
+
+.markdownPayload {
+  min-height: 100%;
+  padding: 14px;
+}
+
+.jsonPayload {
+  min-height: 100%;
+}
+
+.jsonPreview {
+  min-height: 0;
+}
+
+.schema {
+  min-height: 100%;
+  background: var(--dsw-alias-bg-layer-1);
+}
+
+.schemaPreview {
+  min-height: 0;
+}
+
+.schemaIntro {
+  padding: 12px 14px 6px;
+}
+
+.schemaPreview .schemaIntro {
+  padding-top: 6px;
+}
+
+.schemaName {
+  margin: 0;
+  color: var(--dsw-alias-label-primary);
+  font: 600 12px/18px var(--ds-font-family-code);
+}
+
+.schemaDescription {
+  margin: 2px 0 0;
+  color: var(--dsw-alias-label-secondary);
+  font: var(--dsw-font-xs-13);
+  white-space: pre-wrap;
+}
+
+.schemaParameters {
+  min-width: 0;
+}
+
+.schemaParametersTitle {
+  margin: 0;
+  padding: 4px 14px 2px;
+  color: var(--dsw-alias-label-tertiary);
+  font: 600 11px/16px var(--dsw-font-family);
+  user-select: none;
+}
+
+.schemaTree {
+  margin: 0 6px 8px 0;
+}
+
+.payload {
+  box-sizing: border-box;
+  min-height: 100%;
+  margin: 0;
+  padding: 14px;
+  overflow-wrap: anywhere;
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-markdown-code-block);
+  font: 12px/19px var(--ds-font-family-code);
+  tab-size: 2;
+  white-space: pre-wrap;
+}
+
+.payloadPreview {
+  min-height: 0;
+  padding: 6px 14px 8px;
+}
+
+.sourceBlocks {
+  box-sizing: border-box;
+  min-height: 100%;
+  padding: 12px 14px;
+}
+
+.sourceBlock + .sourceBlock {
+  margin-top: 14px;
+}
+
+.sourceBlockHeader,
+.sourceBlockJumpTarget {
+  display: flex;
+  align-items: center;
+  width: max-content;
+  margin-bottom: 3px;
+  gap: 3px;
+  user-select: none;
+}
+
+.sourceBlockJumpTarget {
+  all: unset;
+  display: flex;
+  align-items: center;
+  width: max-content;
+  margin-bottom: 3px;
+  gap: 3px;
+  cursor: pointer;
+  user-select: none;
+}
+
+.sourceBlockLabel {
+  color: var(--dsw-alias-label-tertiary);
+  font: 11px/16px var(--ds-font-family-code);
+}
+
+.sourceBlockJumpIcon {
+  flex: none;
+  color: var(--dsw-alias-label-caption);
+}
+
+.sourceBlockJumpTarget:hover .sourceBlockJumpIcon {
+  color: var(--dsw-alias-label-primary);
+}
+
+.sourceBlockJumpTarget:focus-visible {
+  outline: none;
+}
+
+.sourceBlockJumpTarget:focus-visible .sourceBlockJumpIcon {
+  color: var(--dsw-alias-state-business-primary);
+}
+
+.sourceBlockContent {
+  margin: 0;
+  overflow-wrap: anywhere;
+  color: var(--dsw-alias-label-primary);
+  background: transparent;
+  font: 12px/19px var(--ds-font-family-code);
+  tab-size: 2;
+  white-space: pre-wrap;
+}
+
+.panelImageLink {
+  display: block;
+  width: auto;
+  max-width: 100%;
+  overflow: hidden;
+  border-radius: 4px;
+  cursor: zoom-in;
+}
+
+.panelImageLinkPreview {
+  max-height: 140px;
+}
+
+.panelImage {
+  display: block;
+  width: auto;
+  max-width: 100%;
+  height: auto;
+  max-height: 320px;
+  margin: 0;
+  border-radius: inherit;
+  background: var(--dsw-alias-bg-base);
+  object-fit: contain;
+}
+
+.panelImageLinkPreview .panelImage {
+  max-height: 140px;
+}
+
+.messageImages {
+  display: flex;
+  flex-direction: column;
+  margin: 8px 14px 14px;
+  gap: 8px;
+}
+
+.messageImagesPreview {
+  margin: 6px 14px 8px;
+  gap: 6px;
+}
+
+.assistantToolCalls {
+  box-sizing: border-box;
+  max-width: 100%;
+  margin: 2px 14px 12px;
+  padding: 0;
+  color: var(--dsw-alias-label-secondary);
+  font: 11px/17px var(--ds-font-family-code);
+  list-style: none;
+}
+
+.assistantToolCallsPreview {
+  margin-top: 2px;
+  margin-bottom: 8px;
+}
+
+.assistantToolCalls li {
+  min-width: 0;
+}
+
+.assistantToolCallButton {
+  all: unset;
+  display: flex;
+  box-sizing: border-box;
+  width: 100%;
+  min-width: 0;
+  height: 19px;
+  align-items: center;
+  padding: 0 4px 0 0;
+  border-radius: 3px;
+  cursor: pointer;
+}
+
+.assistantToolCallButton:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.assistantToolCallButton:focus-visible {
+  background: var(--dsw-alias-interactive-bg-hover);
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: -1px;
+}
+
+.assistantToolCallIcon {
+  flex: none;
+  margin-right: 5px;
+  color: var(--dsw-alias-label-caption);
+}
+
+.assistantToolCallButton:hover .assistantToolCallIcon,
+.assistantToolCallButton:focus-visible .assistantToolCallIcon {
+  color: var(--dsw-alias-label-secondary);
+}
+
+.assistantToolCallText {
+  display: flex;
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  white-space: nowrap;
+}
+
+.assistantToolCallName {
+  flex: none;
+  margin-right: 5px;
+  color: var(--dsw-alias-label-secondary);
+  font-weight: 500;
+}
+
+.assistantToolCallArgs {
+  min-width: 0;
+  overflow: hidden;
+  color: var(--dsw-alias-label-tertiary);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.resultBlocks {
+  display: flex;
+  min-height: 100%;
+  flex-direction: column;
+  box-sizing: border-box;
+  padding: 14px;
+  gap: 10px;
+}
+
+.resultBlocksPreview {
+  min-height: 0;
+  padding: 6px 14px 8px;
+  gap: 6px;
+}
+
+.resultBlockText {
+  margin: 0;
+  overflow-wrap: anywhere;
+  color: var(--dsw-alias-label-primary);
+  background: transparent;
+  font: 12px/19px var(--ds-font-family-code);
+  tab-size: 2;
+  white-space: pre-wrap;
+}
+
+.overviewPreview .noPayload {
+  padding: 6px 14px 8px;
+}
+
+.noPayload {
+  margin: 0;
+  padding: 18px 14px;
+  color: var(--dsw-alias-label-tertiary);
+  font: var(--dsw-font-xs-13);
+}
+
+@media (max-width: 760px) {
+  .details {
+    position: absolute;
+    z-index: 5;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    width: min(92%, 420px);
+    max-width: 92%;
+    border-left-color: var(--dsw-alias-border-l3);
+    box-shadow: -12px 0 32px rgba(0, 0, 0, 0.14);
+  }
+}

+ 1301 - 0
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -0,0 +1,1301 @@
+/** Turn-aware trajectory event ledger with a local record inspector. */
+
+import { useRef, useState } from 'react'
+import type { CSSProperties, ReactNode } from 'react'
+import {
+  extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import type {
+  AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
+} from './trajectory-record.ts'
+import { formatElapsedSeconds } from './trajectory-record.ts'
+import type { TrajectoryTurnModel } from './layout.ts'
+import css from './TrajectoryTable.module.css'
+
+const KIND_LABEL: Record<TrajectoryCellKind, string> = {
+  user: 'USER',
+  message: 'ASSISTANT',
+  tool: 'TOOL',
+  subtool: 'SUBTOOL',
+}
+
+interface TableRecord {
+  turn: number
+  group: string
+  groupStart: boolean
+  turnStart: boolean
+  cell: TrajectoryCellProps
+  turnEnd: boolean
+  collapsedSummary?: string
+  collapsedSummaryKind?: 'turn' | 'assistant'
+}
+
+type DetailTab = 'overview' | 'rendered' | 'source' | 'input' | 'output' | 'schema' | 'timing'
+type RecordState = 'complete' | 'running' | 'error'
+
+interface DetailTabItem {
+  id: DetailTab
+  label: string
+}
+
+interface ParentRecords {
+  message?: TableRecord
+  tool?: TableRecord
+}
+
+interface DetailsResizeDrag {
+  pointerId: number
+  startX: number
+  startWidth: number
+  splitWidth: number
+  startToolRequestOffset: number
+}
+
+const DETAILS_MIN_WIDTH = 320
+const DETAILS_MAX_WIDTH = 720
+const TABLE_MIN_WIDTH = 280
+const DETAILS_RESIZE_STEP = 16
+const TOOL_REQUEST_SHARE = 0.58
+const TOOL_REQUEST_MIN_WIDTH = 180
+const TOOL_REQUEST_MAX_WIDTH = 480
+const DEFAULT_TOOL_REQUEST_SHARE = 0.36
+const DEFAULT_TOOL_REQUEST_OFFSET = 56
+
+type TrajectorySplitStyle = CSSProperties & {
+  '--trajectory-tool-request-width': string
+}
+
+function clampDetailsWidth(width: number, splitWidth: number): number {
+  const maxWidth = Math.max(
+    DETAILS_MIN_WIDTH,
+    Math.min(DETAILS_MAX_WIDTH, splitWidth - TABLE_MIN_WIDTH),
+  )
+  return Math.round(Math.min(Math.max(width, DETAILS_MIN_WIDTH), maxWidth))
+}
+
+function defaultToolRequestWidth(splitWidth: number): number {
+  return Math.min(
+    Math.max(
+      splitWidth * DEFAULT_TOOL_REQUEST_SHARE - DEFAULT_TOOL_REQUEST_OFFSET,
+      TOOL_REQUEST_MIN_WIDTH,
+    ),
+    TOOL_REQUEST_MAX_WIDTH,
+  )
+}
+
+function formatDurationMs(milliseconds: number): string {
+  if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
+  return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1)} s`
+}
+
+function formatStartedAt(timestamp: number | null): { label: string; title?: string } {
+  if (timestamp === null || !Number.isFinite(timestamp)) return { label: 'Not available' }
+  const date = new Date(timestamp)
+  const two = (value: number) => String(value).padStart(2, '0')
+  const three = (value: number) => String(value).padStart(3, '0')
+  const time = `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}.${three(date.getMilliseconds())}`
+  const day = `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`
+  return { label: time, title: `${day} ${time}` }
+}
+
+function StartedAtValue({ timestamp }: { timestamp: number | null }) {
+  const formatted = formatStartedAt(timestamp)
+  return <dd title={formatted.title}>{formatted.label}</dd>
+}
+
+function totalTime(metrics: AssistantMetricDetail): string {
+  if (!metrics.timingRecorded) return 'Not recorded'
+  if (metrics.stepStartTime === null) return 'Step start unavailable'
+  if (metrics.completedTime === null) return 'Pending'
+  return formatDurationMs(Math.max(0, metrics.completedTime - metrics.stepStartTime))
+}
+
+function ttft(metrics: AssistantMetricDetail): string {
+  if (!metrics.timingRecorded) return 'Not recorded'
+  if (metrics.stepStartTime === null) return 'Step start unavailable'
+  if (metrics.firstTokenTime === null) return 'First token unavailable'
+  return formatDurationMs(Math.max(0, metrics.firstTokenTime - metrics.stepStartTime))
+}
+
+function generationTime(metrics: AssistantMetricDetail): string {
+  if (!metrics.timingRecorded || metrics.firstTokenTime === null) return 'First token unavailable'
+  if (metrics.completedTime === null) return 'Pending'
+  return formatDurationMs(Math.max(0, metrics.completedTime - metrics.firstTokenTime))
+}
+
+function throughput(metrics: AssistantMetricDetail): string {
+  if (!metrics.usageProvided) return 'Usage unavailable'
+  if (metrics.outputTokens === null) return 'Output tokens unavailable'
+  if (!metrics.timingRecorded || metrics.firstTokenTime === null) return 'First token unavailable'
+  if (metrics.completedTime === null) return 'Pending'
+  const generationSeconds = (metrics.completedTime - metrics.firstTokenTime) / 1_000
+  if (generationSeconds <= 0) return 'Duration too short'
+  return `${(metrics.outputTokens / generationSeconds).toFixed(1)} tok/s`
+}
+
+function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
+  return (
+    <dl className={css.overview}>
+      <div><dt>Started</dt><StartedAtValue timestamp={metrics.stepStartTime} /></div>
+      <div><dt>Total duration</dt><dd>{totalTime(metrics)}</dd></div>
+      <div><dt>TTFT</dt><dd>{ttft(metrics)}</dd></div>
+      <div><dt>Generation</dt><dd>{generationTime(metrics)}</dd></div>
+      <div><dt>Throughput</dt><dd>{throughput(metrics)}</dd></div>
+      <div>
+        <dt>Output tokens</dt>
+        <dd>
+          {!metrics.usageProvided
+            ? 'Usage unavailable'
+            : metrics.outputTokens ?? 'Output tokens unavailable'}
+        </dd>
+      </div>
+    </dl>
+  )
+}
+
+/** Props for the trajectory ledger. */
+export interface TrajectoryTableProps {
+  /** Grouped records in display order. */
+  turns: readonly TrajectoryTurnModel[]
+  /** Turn ids whose rows after the first are folded into a summary. */
+  collapsedTurns: ReadonlySet<number>
+  /** Toggle one turn between folded and expanded. */
+  onToggleTurn(turn: number): void
+  /** Assistant record indexes whose tool calls are folded. */
+  collapsedAssistants: ReadonlySet<number>
+  /** Toggle tool calls under one assistant record. */
+  onToggleAssistant(index: number): void
+}
+
+function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
+  return turns.flatMap((turn) => {
+    let firstInTurn = true
+    const records = turn.groups.flatMap((group) => {
+      return group.cells.map((cell, index) => {
+        const turnStart = firstInTurn && index === 0
+        if (turnStart) firstInTurn = false
+        return {
+          turn: turn.turn,
+          group: group.title,
+          groupStart: index === 0,
+          turnStart,
+          cell,
+          turnEnd: false,
+        }
+      })
+    })
+    const last = records.at(-1)
+    if (last !== undefined) last.turnEnd = true
+    return records
+  })
+}
+
+function summarizeTurn(records: readonly TableRecord[]): string {
+  const userText = records
+    .filter(record => record.cell.kind === 'user')
+    .map(record => recordDisplayText(record.cell))
+    .find(text => text !== '')
+  const assistantText = records
+    .filter(record => record.cell.kind === 'message')
+    .map(record => recordDisplayText(record.cell))
+    .find(text => text !== '')
+  const steps = new Set(
+    records
+      .map(record => record.group)
+      .filter(group => group.startsWith('Step ')),
+  ).size
+  const toolCalls = records.filter(record =>
+    record.cell.kind === 'tool' || record.cell.kind === 'subtool',
+  ).length
+  const parts: string[] = []
+  const message = userText ?? assistantText
+  if (message !== undefined) parts.push(message)
+  parts.push(
+    `${steps} ${steps === 1 ? 'step' : 'steps'}`,
+    `${toolCalls} tool ${toolCalls === 1 ? 'call' : 'calls'}`,
+  )
+  return parts.join(' · ')
+}
+
+function collapseTurnRecords(
+  records: readonly TableRecord[],
+  collapsedTurns: ReadonlySet<number>,
+): TableRecord[] {
+  if (collapsedTurns.size === 0) return [...records]
+  const recordsByTurn = new Map<number, TableRecord[]>()
+  for (const record of records) {
+    const turnRecords = recordsByTurn.get(record.turn) ?? []
+    turnRecords.push(record)
+    recordsByTurn.set(record.turn, turnRecords)
+  }
+  return records.flatMap((record) => {
+    if (!collapsedTurns.has(record.turn)) return [record]
+    const turnRecords = recordsByTurn.get(record.turn) ?? [record]
+    if (turnRecords.length <= 1) return [record]
+    if (!record.turnStart) return []
+    return [
+      { ...record, turnEnd: false },
+      {
+        ...record,
+        groupStart: false,
+        turnStart: false,
+        turnEnd: true,
+        collapsedSummary: summarizeTurn(turnRecords.slice(1)),
+        collapsedSummaryKind: 'turn',
+      },
+    ]
+  })
+}
+
+function assistantToolCalls(
+  records: readonly TableRecord[],
+  assistantIndex: number,
+): readonly TableRecord[] {
+  const at = records.findIndex(record => record.cell.index === assistantIndex)
+  if (at === -1 || records[at]?.cell.kind !== 'message') return []
+  const calls: TableRecord[] = []
+  for (let i = at + 1; i < records.length; i++) {
+    const record = records[i]
+    if (record === undefined) break
+    if (record.cell.kind !== 'tool' && record.cell.kind !== 'subtool') break
+    calls.push(record)
+  }
+  return calls
+}
+
+function summarizeAssistantTools(records: readonly TableRecord[]): string {
+  const names = [...new Set(records.map((record) => {
+    const separator = record.cell.text.indexOf(' · ')
+    return separator === -1 ? record.cell.text : record.cell.text.slice(0, separator)
+  }).filter(name => name !== ''))]
+  const count = records.length
+  const summary = `${count} tool ${count === 1 ? 'call' : 'calls'}`
+  return names.length > 0 ? `${summary} · ${names.join(', ')}` : summary
+}
+
+function collapseAssistantRecords(
+  records: readonly TableRecord[],
+  collapsedAssistants: ReadonlySet<number>,
+): TableRecord[] {
+  if (collapsedAssistants.size === 0) return [...records]
+  const out: TableRecord[] = []
+  for (let i = 0; i < records.length; i++) {
+    const record = records[i]
+    if (record === undefined) continue
+    out.push(record)
+    if (record.cell.kind !== 'message' || !collapsedAssistants.has(record.cell.index)) continue
+    const calls: TableRecord[] = []
+    for (let j = i + 1; j < records.length; j++) {
+      const candidate = records[j]
+      if (
+        candidate === undefined
+        || candidate.collapsedSummary !== undefined
+        || (candidate.cell.kind !== 'tool' && candidate.cell.kind !== 'subtool')
+      ) break
+      calls.push(candidate)
+    }
+    if (calls.length === 0) continue
+    const last = calls.at(-1)
+    out[out.length - 1] = { ...record, turnEnd: false }
+    out.push({
+      ...record,
+      groupStart: false,
+      turnStart: false,
+      turnEnd: last?.turnEnd ?? false,
+      collapsedSummary: summarizeAssistantTools(calls),
+      collapsedSummaryKind: 'assistant',
+    })
+    i += calls.length
+  }
+  return out
+}
+
+function stateOf(record: TableRecord): RecordState {
+  if (record.cell.isError) return 'error'
+  if (
+    (record.cell.kind === 'tool' || record.cell.kind === 'subtool')
+    && record.cell.outputDetail === undefined
+  ) return 'running'
+  return 'complete'
+}
+
+function statusLabel(state: RecordState): string {
+  if (state === 'error') return 'Failed'
+  if (state === 'running') return 'Pending'
+  return 'Completed'
+}
+
+function tokenSummary(cell: TrajectoryCellProps): string {
+  if (cell.kind !== 'message') return '—'
+  return `${cell.input ?? '—'} / ${cell.output ?? '—'} / ${cell.think ?? '—'}`
+}
+
+function isMarkdownRecord(record: TableRecord): boolean {
+  return record.cell.kind === 'user' || record.cell.kind === 'message'
+}
+
+function parentRecords(
+  records: readonly TableRecord[],
+  record: TableRecord,
+): ParentRecords {
+  if (record.cell.kind !== 'tool' && record.cell.kind !== 'subtool') return {}
+  const at = records.findIndex(candidate => candidate.cell.index === record.cell.index)
+  if (at === -1) return {}
+  let tool: TableRecord | undefined
+  if (record.cell.kind === 'subtool') {
+    for (let i = at - 1; i >= 0; i--) {
+      const candidate = records[i]
+      if (
+        candidate === undefined
+        || candidate.turn !== record.turn
+        || candidate.group !== record.group
+      ) break
+      if (candidate.cell.kind === 'tool') {
+        tool = candidate
+        break
+      }
+    }
+  }
+  const parentCallId = tool?.cell.callId ?? record.cell.callId
+  let message: TableRecord | undefined
+  if (parentCallId !== undefined) {
+    message = records.find(candidate =>
+      candidate.turn === record.turn
+      && candidate.cell.kind === 'message'
+      && candidate.cell.sourceBlocks?.some(block => block.callId === parentCallId) === true,
+    )
+  }
+  return { ...(message === undefined ? {} : { message }), ...(tool === undefined ? {} : { tool }) }
+}
+
+function markdownSource(record: TableRecord): string | undefined {
+  if (record.cell.kind === 'user') return record.cell.inputDetail
+  if (record.cell.kind === 'message') return record.cell.outputDetail
+  return undefined
+}
+
+function detailTabs(record: TableRecord): readonly DetailTabItem[] {
+  if (isMarkdownRecord(record)) {
+    return [
+      { id: 'overview', label: 'Summary' },
+      { id: 'rendered', label: 'Preview' },
+      { id: 'source', label: 'Source' },
+      { id: 'timing', label: 'Timing' },
+    ]
+  }
+  return [
+    { id: 'overview', label: 'Summary' },
+    ...(record.cell.inputDetail ? [{ id: 'input', label: 'Payload' } as const] : []),
+    ...(record.cell.outputDetail ? [{ id: 'output', label: 'Result' } as const] : []),
+    { id: 'schema', label: 'Schema' },
+    { id: 'timing', label: 'Timing' },
+  ]
+}
+
+function recordDisplayText(cell: TrajectoryCellProps): string {
+  if (isToolCallOnly(cell)) return ''
+  const markdown = cell.kind === 'user'
+    ? cell.inputDetail
+    : cell.kind === 'message'
+      ? cell.outputDetail ?? cell.thinkingDetail
+      : undefined
+  if (!markdown) return cell.text
+  const plainText = extractMarkdownPlainText(markdown)
+  return plainText.replace(/\s+/g, ' ').trim()
+}
+
+function isToolCallOnly(cell: TrajectoryCellProps): boolean {
+  return cell.kind === 'message'
+    && !cell.outputDetail
+    && !cell.thinkingDetail
+    && cell.text === 'Tool call only'
+}
+
+function MarkdownFragment({
+  text,
+  rendered,
+  preview,
+}: {
+  text: string
+  rendered: boolean
+  preview: boolean
+}) {
+  if (rendered) {
+    return (
+      <div className={preview ? css.markdownPreview : css.markdownPayload}>
+        <MarkdownText text={text} />
+      </div>
+    )
+  }
+  return (
+    <pre className={`${css.payload} ${preview ? css.payloadPreview : ''}`}>
+      {text}
+    </pre>
+  )
+}
+
+function SourceBlocks({
+  blocks,
+  onOpenCall,
+}: {
+  blocks: readonly TrajectorySourceBlock[]
+  onOpenCall(callId: string): void
+}) {
+  return (
+    <div className={css.sourceBlocks}>
+      {blocks.map((block, index) => (
+        <section className={css.sourceBlock} key={index}>
+          {block.callId !== undefined
+            ? (
+                <button
+                  type="button"
+                  className={css.sourceBlockJumpTarget}
+                  aria-label={`Open Block #${index + 1} tool call summary`}
+                  title="Open tool call summary"
+                  onClick={() => {
+                    if (block.callId !== undefined) onOpenCall(block.callId)
+                  }}
+                >
+                  <span className={css.sourceBlockLabel}>
+                    {`Block #${index + 1} ${block.type}`}
+                  </span>
+                  <IconChevronRightOutline14 className={css.sourceBlockJumpIcon} size={12} />
+                </button>
+              )
+            : (
+                <div className={css.sourceBlockHeader}>
+                  <span className={css.sourceBlockLabel}>
+                    {`Block #${index + 1} ${block.type}`}
+                  </span>
+                </div>
+              )}
+          {block.imageSrc !== undefined
+            ? <PanelImage block={block} />
+            : <pre className={css.sourceBlockContent}>{block.content}</pre>}
+        </section>
+      ))}
+    </div>
+  )
+}
+
+function PanelImage({
+  block,
+  preview = false,
+}: {
+  block: TrajectorySourceBlock
+  preview?: boolean
+}) {
+  if (block.imageSrc === undefined) return null
+  return (
+    <a
+      className={preview ? `${css.panelImageLink} ${css.panelImageLinkPreview}` : css.panelImageLink}
+      href={block.imageSrc}
+      target="_blank"
+      rel="noopener noreferrer"
+      title="Open image"
+    >
+      <img
+        className={css.panelImage}
+        src={block.imageSrc}
+        alt={block.imageAlt ?? ''}
+      />
+    </a>
+  )
+}
+
+function MessageImages({
+  blocks,
+  preview,
+}: {
+  blocks: readonly TrajectorySourceBlock[] | undefined
+  preview: boolean
+}) {
+  const images = blocks?.filter(block => block.imageSrc !== undefined) ?? []
+  if (images.length === 0) return null
+  return (
+    <div className={preview ? `${css.messageImages} ${css.messageImagesPreview}` : css.messageImages}>
+      {images.map((block, index) => <PanelImage block={block} preview={preview} key={index} />)}
+    </div>
+  )
+}
+
+function AssistantToolCalls({
+  blocks,
+  preview,
+  onOpenCall,
+}: {
+  blocks: readonly TrajectorySourceBlock[] | undefined
+  preview: boolean
+  onOpenCall(callId: string): void
+}) {
+  const calls = blocks?.filter(block => block.type === 'tool-call') ?? []
+  if (calls.length === 0) return null
+  return (
+    <ul className={preview
+      ? `${css.assistantToolCalls} ${css.assistantToolCallsPreview}`
+      : css.assistantToolCalls}
+    >
+      {calls.map((call, index) => (
+        <li key={call.callId ?? index}>
+          <button
+            type="button"
+            className={css.assistantToolCallButton}
+            title="Open tool call summary"
+            onClick={() => {
+              if (call.callId !== undefined) onOpenCall(call.callId)
+            }}
+          >
+            <svg
+              className={css.assistantToolCallIcon}
+              width="12"
+              height="12"
+              viewBox="0 0 24 24"
+              fill="none"
+              aria-hidden="true"
+            >
+              <path
+                d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94z"
+                stroke="currentColor"
+                strokeWidth="1.8"
+                strokeLinecap="round"
+                strokeLinejoin="round"
+              />
+            </svg>
+            <span className={css.assistantToolCallText}>
+              <span className={css.assistantToolCallName}>
+                {call.toolName ?? 'tool-call'}
+              </span>
+              {call.content !== '' && (
+                <span className={css.assistantToolCallArgs}>{call.content}</span>
+              )}
+            </span>
+          </button>
+        </li>
+      ))}
+    </ul>
+  )
+}
+
+function ToolOutputBlocks({
+  blocks,
+  preview,
+}: {
+  blocks: readonly TrajectorySourceBlock[]
+  preview: boolean
+}) {
+  return (
+    <div className={preview ? `${css.resultBlocks} ${css.resultBlocksPreview}` : css.resultBlocks}>
+      {blocks.map((block, index) => (
+        block.imageSrc !== undefined
+          ? <PanelImage block={block} preview={preview} key={index} />
+          : block.content !== ''
+            ? <pre className={css.resultBlockText} key={index}>{block.content}</pre>
+            : null
+      ))}
+    </div>
+  )
+}
+
+function MarkdownRecordContent({
+  record,
+  rendered,
+  preview = false,
+  thinkingExpanded,
+  onThinkingExpandedChange,
+  onOpenCall,
+}: {
+  record: TableRecord
+  rendered: boolean
+  preview?: boolean
+  thinkingExpanded: boolean
+  onThinkingExpandedChange(expanded: boolean): void
+  onOpenCall(callId: string): void
+}) {
+  if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) {
+    return <SourceBlocks blocks={record.cell.sourceBlocks} onOpenCall={onOpenCall} />
+  }
+  if (record.cell.kind === 'message' && record.cell.thinkingDetail) {
+    if (!rendered) {
+      const source = [
+        record.cell.thinkingDetail,
+        record.cell.outputDetail,
+      ].filter((value): value is string => value !== undefined && value !== '').join('\n\n')
+      return <MarkdownFragment text={source} rendered={false} preview={preview} />
+    }
+    return (
+      <div className={rendered
+        ? `${css.assistantContent} ${css.assistantContentRendered}`
+        : css.assistantContent}
+      >
+        <div className={
+          preview && !record.cell.outputDetail
+            ? `${css.thinkingQuote} ${css.thinkingQuoteOnlyPreview}`
+            : css.thinkingQuote
+        }
+        >
+          <button
+            type="button"
+            className={css.thinkingToggle}
+            aria-expanded={thinkingExpanded}
+            onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }}
+          >
+            {thinkingExpanded ? 'Thinking' : 'Thinking ...'}
+          </button>
+          {thinkingExpanded && (
+            <MarkdownFragment
+              text={record.cell.thinkingDetail}
+              rendered={rendered}
+              preview={preview}
+            />
+          )}
+        </div>
+        {record.cell.outputDetail && (
+          <div className={css.assistantOutput}>
+            <MarkdownFragment
+              text={record.cell.outputDetail}
+              rendered={rendered}
+              preview={preview}
+            />
+          </div>
+        )}
+        <AssistantToolCalls
+          blocks={record.cell.sourceBlocks}
+          preview={preview}
+          onOpenCall={onOpenCall}
+        />
+        <MessageImages
+          blocks={record.cell.sourceBlocks}
+          preview={preview}
+        />
+      </div>
+    )
+  }
+  const source = markdownSource(record)
+  const hasImages = record.cell.sourceBlocks?.some(block => block.imageSrc !== undefined) === true
+  const hasToolCalls = record.cell.kind === 'message'
+    && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true
+  if (!source && !hasImages && !hasToolCalls) {
+    const emptyLabel = isToolCallOnly(record.cell)
+      ? 'Tool call only'
+      : record.cell.text || 'No content'
+    return <p className={css.noPayload}>{emptyLabel}</p>
+  }
+  if (!rendered || (!hasImages && !hasToolCalls)) {
+    return <MarkdownFragment text={source ?? ''} rendered={rendered} preview={preview} />
+  }
+  return (
+    <div>
+      {source && <MarkdownFragment text={source} rendered preview={preview} />}
+      {record.cell.kind === 'message' && (
+        <AssistantToolCalls
+          blocks={record.cell.sourceBlocks}
+          preview={preview}
+          onOpenCall={onOpenCall}
+        />
+      )}
+      <MessageImages blocks={record.cell.sourceBlocks} preview={preview} />
+    </div>
+  )
+}
+
+function RecordTiming({ record }: { record: TableRecord }) {
+  return record.cell.kind === 'message' && record.cell.assistantMetrics !== undefined
+    ? <AssistantTimingPanel metrics={record.cell.assistantMetrics} />
+    : (
+        <dl className={css.overview}>
+          <div><dt>Started</dt><StartedAtValue timestamp={record.cell.startedAt ?? null} /></div>
+          <div><dt>Duration</dt><dd>{formatElapsedSeconds(record.cell.timeSeconds)}</dd></div>
+          <div><dt>Timing source</dt><dd>{record.cell.timeSeconds === null ? 'Not available' : 'Session timestamps'}</dd></div>
+        </dl>
+      )
+}
+
+function RecordPayload({
+  record,
+  direction,
+  preview = false,
+}: {
+  record: TableRecord
+  direction: 'input' | 'output'
+  preview?: boolean
+}) {
+  const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail
+  const missing = direction === 'input'
+    ? 'No payload captured'
+    : 'No result captured'
+  if (!value) return <p className={css.noPayload}>{missing}</p>
+
+  if (direction === 'output' && record.cell.outputBlocks && record.cell.outputBlocks.length > 0) {
+    return (
+      <ToolOutputBlocks
+        blocks={record.cell.outputBlocks}
+        preview={preview}
+      />
+    )
+  }
+
+  const markdown = (
+    direction === 'input' && record.cell.kind === 'user'
+  ) || (
+    direction === 'output' && record.cell.kind === 'message'
+  )
+  if (markdown) {
+    return (
+      <div className={preview ? css.markdownPreview : css.markdownPayload}>
+        <MarkdownText text={value} />
+      </div>
+    )
+  }
+  const json = parseJsonContainer(value)
+  if (json !== undefined) {
+    return (
+      <JsonTree
+        data={json}
+        label={`${direction === 'input' ? 'Payload' : 'Result'} JSON`}
+        className={preview ? css.jsonPreview! : css.jsonPayload!}
+      />
+    )
+  }
+  return (
+    <pre className={[
+      css.payload,
+      preview ? css.payloadPreview : undefined,
+      record.cell.isError ? css.error : undefined,
+    ].filter((value): value is string => value !== undefined).join(' ')}
+    >
+      {value}
+    </pre>
+  )
+}
+
+function RecordSchema({
+  record,
+  preview = false,
+}: {
+  record: TableRecord
+  preview?: boolean
+}) {
+  if (!record.cell.schemaDetail) {
+    return <p className={css.noPayload}>Schema unavailable</p>
+  }
+  const schema = parseToolSchema(record.cell.schemaDetail)
+  if (schema !== undefined) {
+    return (
+      <div className={preview ? `${css.schema} ${css.schemaPreview}` : css.schema}>
+        <header className={css.schemaIntro}>
+          <h3 className={css.schemaName}>{schema.name}</h3>
+          <p className={css.schemaDescription}>{schema.description}</p>
+        </header>
+        <section className={css.schemaParameters}>
+          <h4 className={css.schemaParametersTitle}>Parameters</h4>
+          <JsonTree
+            data={schema.parameters}
+            label={`${schema.name} parameters JSON`}
+            className={css.schemaTree}
+          />
+        </section>
+      </div>
+    )
+  }
+  return (
+    <pre className={`${css.payload} ${preview ? css.payloadPreview : ''}`}>
+      {record.cell.schemaDetail}
+    </pre>
+  )
+}
+
+interface ParsedToolSchema {
+  name: string
+  description: string
+  parameters: object
+}
+
+function parseToolSchema(value: string): ParsedToolSchema | undefined {
+  try {
+    const parsed: unknown = JSON.parse(value)
+    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
+    const schema = parsed as Record<string, unknown>
+    if (
+      typeof schema.name !== 'string'
+      || typeof schema.description !== 'string'
+      || typeof schema.parameters !== 'object'
+      || schema.parameters === null
+      || Array.isArray(schema.parameters)
+    ) return undefined
+    return {
+      name: schema.name,
+      description: schema.description,
+      parameters: schema.parameters,
+    }
+  } catch {
+    return undefined
+  }
+}
+
+function parseJsonContainer(value: string): object | undefined {
+  try {
+    const parsed: unknown = JSON.parse(value)
+    return typeof parsed === 'object' && parsed !== null ? parsed : undefined
+  } catch {
+    return undefined
+  }
+}
+
+function OverviewSection({
+  label,
+  onOpen,
+  children,
+}: {
+  label: string
+  onOpen(): void
+  children: ReactNode
+}) {
+  return (
+    <section className={css.overviewSection}>
+      <h3 className={css.overviewHeading}>
+        <button
+          type="button"
+          className={css.overviewTitle}
+          onClick={onOpen}
+        >
+          <span>{label}</span>
+          <IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} />
+        </button>
+      </h3>
+      <div className={css.overviewPreview}>{children}</div>
+    </section>
+  )
+}
+
+/**
+ * Render trajectory events as a dense ledger with turn and step separators.
+ * @param props - Grouped trajectory data and whole-ledger fold state.
+ * @returns The ledger and an optional local record inspector.
+ */
+export function TrajectoryTable({
+  turns,
+  collapsedTurns,
+  onToggleTurn,
+  collapsedAssistants,
+  onToggleAssistant,
+}: TrajectoryTableProps) {
+  const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
+  const [activeTab, setActiveTab] = useState<DetailTab>('overview')
+  const [thinkingExpanded, setThinkingExpanded] = useState(true)
+  const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
+  const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
+  const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
+  const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
+  const allRecords = flattenRecords(turns)
+  const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
+  const records = collapseAssistantRecords(turnRecords, collapsedAssistants)
+  const selected = allRecords.find(record => record.cell.index === selectedIndex)
+  const selectedState = selected === undefined ? undefined : stateOf(selected)
+  const selectedTabs = selected === undefined ? [] : detailTabs(selected)
+  const selectedParents: ParentRecords = selected === undefined
+    ? {}
+    : parentRecords(allRecords, selected)
+  const hasSelectedParents = selectedParents.message !== undefined
+    || selectedParents.tool !== undefined
+  const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null
+    ? undefined
+    : {
+        '--trajectory-tool-request-width': `calc(58cqw - ${toolRequestOffset}px)`,
+      }
+
+  const activateTab = (tab: DetailTab) => {
+    tabHistory.current.delete(tab)
+    tabHistory.current.add(tab)
+    setActiveTab(tab)
+  }
+
+  const selectRecord = (index: number) => {
+    const record = allRecords.find(candidate => candidate.cell.index === index)
+    setSelectedIndex(index)
+    if (record === undefined) return
+    const available = new Set(detailTabs(record).map(tab => tab.id))
+    const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
+    setActiveTab(recent ?? 'overview')
+  }
+
+  const openRecordSummary = (target: TableRecord) => {
+    const targetAt = allRecords.findIndex(record => record.cell.index === target.cell.index)
+    if (collapsedTurns.has(target.turn)) onToggleTurn(target.turn)
+    if (target.cell.kind === 'tool' || target.cell.kind === 'subtool') {
+      for (let i = targetAt - 1; i >= 0; i--) {
+        const candidate = allRecords[i]
+        if (candidate === undefined || candidate.turn !== target.turn) break
+        if (candidate.cell.kind !== 'message') continue
+        if (collapsedAssistants.has(candidate.cell.index)) onToggleAssistant(candidate.cell.index)
+        break
+      }
+    }
+    setSelectedIndex(target.cell.index)
+    activateTab('overview')
+  }
+
+  const openCallSummary = (callId: string) => {
+    const target = allRecords.find(record => record.cell.callId === callId)
+    if (target !== undefined) openRecordSummary(target)
+  }
+
+  return (
+    <div className={css.split} style={splitStyle}>
+      <div className={css.tablePane}>
+        <table className={css.table}>
+          <colgroup>
+            <col className={css.eventColumn} />
+            <col className={css.contentColumn} />
+          </colgroup>
+          <tbody>
+            {records.map((record) => {
+              const displayText = recordDisplayText(record.cell)
+              const isCollapsedSummary = record.collapsedSummary !== undefined
+              return (
+                <tr
+                  key={`${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`}
+                  tabIndex={0}
+                  aria-label={isCollapsedSummary
+                    ? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
+                    : `${KIND_LABEL[record.cell.kind]}, ${displayText || 'no content'}`}
+                  aria-selected={!isCollapsedSummary && selectedIndex === record.cell.index}
+                  data-kind={record.cell.kind}
+                  data-group-start={record.groupStart || undefined}
+                  data-turn-start={record.turnStart || undefined}
+                  data-error={record.cell.isError || undefined}
+                  data-running={stateOf(record) === 'running' || undefined}
+                  data-turn-end={record.turnEnd || undefined}
+                  data-collapsed-summary={record.collapsedSummaryKind}
+                  data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
+                  onClick={isCollapsedSummary
+                    ? () => {
+                        if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
+                        else onToggleAssistant(record.cell.index)
+                      }
+                    : () => { selectRecord(record.cell.index) }}
+                  onDoubleClick={(event) => {
+                    if (isCollapsedSummary) return
+                    if (collapsedTurns.has(record.turn)) {
+                      event.preventDefault()
+                      onToggleTurn(record.turn)
+                      return
+                    }
+                    if (
+                      record.cell.kind === 'message'
+                      && assistantToolCalls(allRecords, record.cell.index).length > 0
+                    ) {
+                      event.preventDefault()
+                      onToggleAssistant(record.cell.index)
+                      return
+                    }
+                    if (!record.turnStart) return
+                    if (allRecords.filter(candidate => candidate.turn === record.turn).length <= 1) return
+                    event.preventDefault()
+                    onToggleTurn(record.turn)
+                  }}
+                  onKeyDown={(event) => {
+                    if (event.key !== 'Enter' && event.key !== ' ') return
+                    event.preventDefault()
+                    if (isCollapsedSummary) {
+                      if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
+                      else onToggleAssistant(record.cell.index)
+                      return
+                    }
+                    selectRecord(record.cell.index)
+                  }}
+                >
+                <td className={css.event}>
+                  {selected?.turn === record.turn && (
+                    <span className={css.turnRail} aria-hidden="true" />
+                  )}
+                  {!isCollapsedSummary && selectedIndex === record.cell.index && (
+                    <span className={css.selectionRail} aria-hidden="true" />
+                  )}
+                  <div className={css.eventInner}>
+                    {!isCollapsedSummary && (
+                      <span
+                        className={
+                          record.cell.kind === 'user' || record.cell.kind === 'message'
+                            ? `${css.kindSlot} ${css.kindSlotLeft}`
+                            : `${css.kindSlot} ${css.kindSlotRight}`
+                        }
+                      >
+                        <span className={`${css.kindTag} ${css[record.cell.kind]}`}>
+                          {KIND_LABEL[record.cell.kind]}
+                        </span>
+                        {record.turnStart && record.cell.opensTurn && (
+                          <span
+                            className={selected?.turn === record.turn
+                              ? `${css.turnLabel} ${css.turnLabelActive}`
+                              : css.turnLabel}
+                          >
+                            Turn {record.turn}
+                          </span>
+                        )}
+                      </span>
+                    )}
+                  </div>
+                </td>
+                <td className={css.content}>
+                  {record.collapsedSummary !== undefined
+                    ? (
+                        <span className={css.collapsedTurnContent} title={record.collapsedSummary}>
+                          <span className={css.collapsedTurnEllipsis}>…</span>
+                          <span className={css.collapsedTurnText}>{record.collapsedSummary}</span>
+                        </span>
+                      )
+                    : (
+                        <span
+                          className={record.cell.result === undefined ? css.contentText : css.resultPreview}
+                          title={record.cell.result === undefined
+                            ? displayText
+                            : `${displayText} → ${record.cell.result}`}
+                        >
+                          <span className={record.cell.result === undefined ? undefined : css.resultRequest}>
+                            {isToolCallOnly(record.cell) ? null : displayText || '—'}
+                          </span>
+                          {record.cell.result !== undefined && (
+                            <span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
+                              <span className={css.arrow}>→</span>
+                              <span className={css.inlineResultText}>{record.cell.result}</span>
+                            </span>
+                          )}
+                        </span>
+                      )}
+                </td>
+                </tr>
+              )
+            })}
+          </tbody>
+        </table>
+      </div>
+      {selected !== undefined && selectedState !== undefined && (
+        <aside
+          className={css.details}
+          aria-label="Event details"
+          style={detailsWidth === null ? undefined : { width: detailsWidth }}
+        >
+          <div
+            className={css.detailsResizeHandle}
+            role="separator"
+            aria-label="Resize event details"
+            aria-controls="trajectory-detail-panel"
+            aria-orientation="vertical"
+            tabIndex={0}
+            title="Drag to resize. Double-click to reset."
+            onDoubleClick={() => {
+              setDetailsWidth(null)
+              setToolRequestOffset(null)
+            }}
+            onPointerDown={(event) => {
+              if (event.button !== 0) return
+              const details = event.currentTarget.parentElement
+              const split = details?.parentElement
+              if (details === null || details === undefined || split === null || split === undefined) return
+              const splitWidth = split.getBoundingClientRect().width
+              detailsResizeDrag.current = {
+                pointerId: event.pointerId,
+                startX: event.clientX,
+                startWidth: details.getBoundingClientRect().width,
+                splitWidth,
+                startToolRequestOffset: toolRequestOffset ?? (
+                  splitWidth * TOOL_REQUEST_SHARE - defaultToolRequestWidth(splitWidth)
+                ),
+              }
+              event.currentTarget.setPointerCapture(event.pointerId)
+              event.preventDefault()
+            }}
+            onPointerMove={(event) => {
+              const drag = detailsResizeDrag.current
+              if (drag === null || drag.pointerId !== event.pointerId) return
+              const nextDetailsWidth = clampDetailsWidth(
+                drag.startWidth + drag.startX - event.clientX,
+                drag.splitWidth,
+              )
+              setDetailsWidth(nextDetailsWidth)
+              setToolRequestOffset(
+                drag.startToolRequestOffset
+                + (nextDetailsWidth - drag.startWidth) * TOOL_REQUEST_SHARE,
+              )
+            }}
+            onPointerUp={(event) => {
+              if (detailsResizeDrag.current?.pointerId !== event.pointerId) return
+              detailsResizeDrag.current = null
+              event.currentTarget.releasePointerCapture(event.pointerId)
+            }}
+            onPointerCancel={() => {
+              detailsResizeDrag.current = null
+            }}
+            onKeyDown={(event) => {
+              if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
+              const details = event.currentTarget.parentElement
+              const split = details?.parentElement
+              if (details === null || details === undefined || split === null || split === undefined) return
+              const direction = event.key === 'ArrowLeft' ? 1 : -1
+              const currentDetailsWidth = details.getBoundingClientRect().width
+              const splitWidth = split.getBoundingClientRect().width
+              const nextDetailsWidth = clampDetailsWidth(
+                currentDetailsWidth + direction * DETAILS_RESIZE_STEP,
+                splitWidth,
+              )
+              const currentToolRequestOffset = toolRequestOffset ?? (
+                splitWidth * TOOL_REQUEST_SHARE - defaultToolRequestWidth(splitWidth)
+              )
+              setDetailsWidth(nextDetailsWidth)
+              setToolRequestOffset(
+                currentToolRequestOffset
+                + (nextDetailsWidth - currentDetailsWidth) * TOOL_REQUEST_SHARE,
+              )
+              event.preventDefault()
+            }}
+          />
+          <div className={css.detailsHeader}>
+            <div className={css.detailsTitle}>
+              <span className={`${css.kindTag} ${css[selected.cell.kind]}`}>
+                {KIND_LABEL[selected.cell.kind]}
+              </span>
+              <span className={css.detailsLocation}>{`Turn ${selected.turn} · ${selected.group}`}</span>
+            </div>
+            <button
+              type="button"
+              className={css.close}
+              aria-label="Close details"
+              onClick={() => { setSelectedIndex(null) }}
+            >
+              <span aria-hidden="true">×</span>
+            </button>
+          </div>
+          <div className={css.detailTabs} role="tablist" aria-label="Event details">
+            {selectedTabs.map(tab => (
+              <button
+                key={tab.id}
+                id={`trajectory-detail-${tab.id}`}
+                type="button"
+                role="tab"
+                aria-controls="trajectory-detail-panel"
+                aria-selected={activeTab === tab.id}
+                className={activeTab === tab.id ? `${css.detailTab} ${css.detailTabActive}` : css.detailTab}
+                onClick={() => { activateTab(tab.id) }}
+              >
+                {tab.label}
+              </button>
+            ))}
+          </div>
+          <div
+            id="trajectory-detail-panel"
+            className={css.detailBody}
+            role="tabpanel"
+            aria-labelledby={`trajectory-detail-${activeTab}`}
+          >
+            {activeTab === 'overview' && (
+              <>
+                <dl className={css.overview}>
+                  {hasSelectedParents && (
+                    <div>
+                      <dt>Hierarchy</dt>
+                      <dd className={css.overviewParentLinks}>
+                        {selectedParents.message !== undefined && (
+                          <button
+                            type="button"
+                            className={css.overviewHierarchyLink}
+                            onClick={() => { openRecordSummary(selectedParents.message!) }}
+                          >
+                            Parent Message
+                          </button>
+                        )}
+                        {selectedParents.tool !== undefined && (
+                          <button
+                            type="button"
+                            className={css.overviewHierarchyLink}
+                            onClick={() => { openRecordSummary(selectedParents.tool!) }}
+                          >
+                            Parent Tool Call
+                          </button>
+                        )}
+                      </dd>
+                    </div>
+                  )}
+                  <div>
+                    <dt>Status</dt>
+                    <dd>{statusLabel(selectedState)}</dd>
+                  </div>
+                  {selected.cell.kind === 'message' && (
+                    <div><dt>Tokens</dt><dd>{tokenSummary(selected.cell)}</dd></div>
+                  )}
+                  <div><dt>Duration</dt><dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd></div>
+                </dl>
+                <div className={css.overviewSections}>
+                  {isMarkdownRecord(selected)
+                    ? (
+                        <>
+                          <OverviewSection label="Preview" onOpen={() => { activateTab('rendered') }}>
+                            <MarkdownRecordContent
+                              record={selected}
+                              rendered
+                              preview
+                              thinkingExpanded={thinkingExpanded}
+                              onThinkingExpandedChange={setThinkingExpanded}
+                              onOpenCall={openCallSummary}
+                            />
+                          </OverviewSection>
+                        </>
+                      )
+                    : (
+                        <>
+                          {selected.cell.inputDetail && (
+                            <OverviewSection label="Payload" onOpen={() => { activateTab('input') }}>
+                              <RecordPayload record={selected} direction="input" preview />
+                            </OverviewSection>
+                          )}
+                          {selected.cell.outputDetail && (
+                            <OverviewSection label="Result" onOpen={() => { activateTab('output') }}>
+                              <RecordPayload record={selected} direction="output" preview />
+                            </OverviewSection>
+                          )}
+                          <OverviewSection label="Schema" onOpen={() => { activateTab('schema') }}>
+                            <RecordSchema record={selected} preview />
+                          </OverviewSection>
+                        </>
+                      )}
+                  <OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}>
+                    <RecordTiming record={selected} />
+                  </OverviewSection>
+                </div>
+              </>
+            )}
+            {activeTab === 'rendered' && (
+              <MarkdownRecordContent
+                record={selected}
+                rendered
+                thinkingExpanded={thinkingExpanded}
+                onThinkingExpandedChange={setThinkingExpanded}
+                onOpenCall={openCallSummary}
+              />
+            )}
+            {activeTab === 'source' && (
+              <MarkdownRecordContent
+                record={selected}
+                rendered={false}
+                thinkingExpanded={thinkingExpanded}
+                onThinkingExpandedChange={setThinkingExpanded}
+                onOpenCall={openCallSummary}
+              />
+            )}
+            {activeTab === 'input' && (
+              <RecordPayload record={selected} direction="input" />
+            )}
+            {activeTab === 'output' && (
+              <RecordPayload record={selected} direction="output" />
+            )}
+            {activeTab === 'schema' && (
+              <RecordSchema record={selected} />
+            )}
+            {activeTab === 'timing' && (
+              <RecordTiming record={selected} />
+            )}
+          </div>
+        </aside>
+      )}
+    </div>
+  )
+}

+ 85 - 0
packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css

@@ -0,0 +1,85 @@
+.root {
+  position: sticky;
+  top: 0;
+  z-index: 4;
+  box-sizing: border-box;
+  width: 100%;
+  height: var(--dsh-trajectory-toolbar-height);
+  border-bottom: 1px solid var(--dsw-alias-border-l2);
+  background: var(--dsw-alias-bg-layer-1);
+}
+
+.inner {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  box-sizing: border-box;
+  width: 100%;
+  height: 100%;
+  padding: 0 14px 0 16px;
+}
+
+.summary {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+  gap: 10px;
+}
+
+.title {
+  flex: none;
+  color: var(--dsw-alias-label-primary);
+  font: var(--dsw-font-xs-strong-13);
+}
+
+.actions {
+  display: flex;
+  flex: none;
+  align-items: center;
+  gap: 2px;
+}
+
+.action {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  box-sizing: border-box;
+  height: 26px;
+  padding: 0 7px;
+  gap: 6px;
+  border: 0;
+  border-radius: 4px;
+  color: var(--dsw-alias-label-tertiary);
+  background: transparent;
+  cursor: pointer;
+  font: var(--dsw-font-xs-13);
+  transition:
+    color 120ms var(--ds-ease-in-out),
+    background-color 120ms var(--ds-ease-in-out);
+}
+
+.action:hover:not(:disabled) {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.action:focus-visible {
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: 2px;
+}
+
+.action:disabled {
+  color: var(--dsw-alias-label-dimmed);
+  cursor: not-allowed;
+}
+
+.actionIcon {
+  color: var(--dsw-alias-label-tertiary);
+  font: 13px/13px var(--ds-font-family-code);
+}
+
+@media (max-width: 720px) {
+  .summary {
+    gap: 7px;
+  }
+}

+ 66 - 0
packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx

@@ -0,0 +1,66 @@
+/** Trajectory toolbar: view identity, record totals, and the ledger fold control. */
+
+import css from './TrajectoryToolbar.module.css'
+
+export interface TrajectoryToolbarProps {
+  /** Number of turns containing more than one row. */
+  collapsibleTurns: number
+  /** Whether every collapsible turn is currently folded. */
+  allTurnsCollapsed: boolean
+  /** Fold or expand every collapsible turn. */
+  onToggleAllTurns(): void
+  /** Number of assistant messages followed by tool calls. */
+  collapsibleAssistants: number
+  /** Whether every collapsible assistant's tool calls are currently folded. */
+  allAssistantsCollapsed: boolean
+  /** Fold or expand tool calls under every collapsible assistant. */
+  onToggleAllAssistants(): void
+}
+
+/**
+ * Render the sticky trajectory toolbar.
+ * @param props - rendered counts and whole-list fold state.
+ * @returns the toolbar element.
+ */
+export function TrajectoryToolbar({
+  collapsibleTurns,
+  allTurnsCollapsed,
+  onToggleAllTurns,
+  collapsibleAssistants,
+  allAssistantsCollapsed,
+  onToggleAllAssistants,
+}: TrajectoryToolbarProps) {
+  return (
+    <div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
+      <div className={css.inner}>
+        <div className={css.summary}>
+          <span className={css.title}>Trajectory</span>
+        </div>
+        <div className={css.actions}>
+          <button
+            type="button"
+            className={css.action}
+            disabled={collapsibleAssistants === 0}
+            onClick={onToggleAllAssistants}
+          >
+            <span className={css.actionIcon} aria-hidden="true">
+              {allAssistantsCollapsed ? '⊞' : '⊟'}
+            </span>
+            {allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
+          </button>
+          <button
+            type="button"
+            className={css.action}
+            disabled={collapsibleTurns === 0}
+            onClick={onToggleAllTurns}
+          >
+            <span className={css.actionIcon} aria-hidden="true">
+              {allTurnsCollapsed ? '⊞' : '⊟'}
+            </span>
+            {allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 92 - 23
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -1,41 +1,110 @@
-// TrajectoryView: sticky Turn sections with Message/Step groups and step cells.
+/** Trajectory view: compact summary over a turn-aware event ledger. */
 
-import { useMemo } from 'react'
+import { useMemo, useState } from 'react'
 import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { TrajectoryCell } from './TrajectoryCell.tsx'
-import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx'
-import { TrajectoryTurn } from './TrajectoryTurn.tsx'
+import { TrajectoryTable } from './TrajectoryTable.tsx'
+import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
 import { deriveTrajectoryLayout } from './layout.ts'
 import css from './views.module.css'
 
 export function TrajectoryView({ useSession }: ConvViewProps) {
+  const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(() => new Set())
+  const [collapsedAssistants, setCollapsedAssistants] = useState<ReadonlySet<number>>(() => new Set())
   const nodes = useSession((s) => s.nodes)
   const partial = useSession((s) => s.partial)
   const runningCalls = useSession((s) => s.runningCalls)
+  const callSchemas = useSession((s) => s.callSchemas)
   const codeDispatches = useSession((s) => s.codeDispatches)
   const turns = useMemo(
-    () => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
-    [nodes, partial, runningCalls, codeDispatches],
+    () => deriveTrajectoryLayout({ nodes, partial, runningCalls, callSchemas, codeDispatches }),
+    [nodes, partial, runningCalls, callSchemas, codeDispatches],
   )
-  if (turns.length === 0) {
-    return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
+  const collapsibleTurnIds = useMemo(
+    () => turns
+      .filter(turn => turn.groups.reduce((count, group) => count + group.cells.length, 0) > 1)
+      .map(turn => turn.turn),
+    [turns],
+  )
+  const allTurnsCollapsed = collapsibleTurnIds.length > 0
+    && collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
+  const collapsibleAssistantIds = useMemo(() => {
+    const ids: number[] = []
+    for (const turn of turns) {
+      const cells = turn.groups.flatMap(group => group.cells)
+      for (let i = 0; i < cells.length; i++) {
+        const cell = cells[i]
+        if (cell?.kind !== 'message') continue
+        const next = cells[i + 1]
+        if (next?.kind === 'tool' || next?.kind === 'subtool') ids.push(cell.index)
+      }
+    }
+    return ids
+  }, [turns])
+  const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
+    && collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
+
+  const toggleTurn = (turn: number) => {
+    setCollapsedTurns((current) => {
+      const next = new Set(current)
+      if (next.has(turn)) next.delete(turn)
+      else next.add(turn)
+      return next
+    })
   }
+
+  const toggleAllTurns = () => {
+    setCollapsedTurns((current) => {
+      const next = new Set(current)
+      if (allTurnsCollapsed) {
+        for (const turn of collapsibleTurnIds) next.delete(turn)
+      } else {
+        for (const turn of collapsibleTurnIds) next.add(turn)
+      }
+      return next
+    })
+  }
+
+  const toggleAssistant = (index: number) => {
+    setCollapsedAssistants((current) => {
+      const next = new Set(current)
+      if (next.has(index)) next.delete(index)
+      else next.add(index)
+      return next
+    })
+  }
+
+  const toggleAllAssistants = () => {
+    setCollapsedAssistants((current) => {
+      const next = new Set(current)
+      if (allAssistantsCollapsed) {
+        for (const index of collapsibleAssistantIds) next.delete(index)
+      } else {
+        for (const index of collapsibleAssistantIds) next.add(index)
+      }
+      return next
+    })
+  }
+
   return (
     <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>
-      ))}
+      <TrajectoryToolbar
+        collapsibleTurns={collapsibleTurnIds.length}
+        allTurnsCollapsed={allTurnsCollapsed}
+        onToggleAllTurns={toggleAllTurns}
+        collapsibleAssistants={collapsibleAssistantIds.length}
+        allAssistantsCollapsed={allAssistantsCollapsed}
+        onToggleAllAssistants={toggleAllAssistants}
+      />
+      {turns.length === 0 && <p className={css.empty}>No trajectory events</p>}
+      {turns.length > 0 && (
+        <TrajectoryTable
+          turns={turns}
+          collapsedTurns={collapsedTurns}
+          onToggleTurn={toggleTurn}
+          collapsedAssistants={collapsedAssistants}
+          onToggleAssistant={toggleAssistant}
+        />
+      )}
     </div>
   )
 }

+ 1 - 1
packages/client/ui-trajectory/src/client/WaterfallView.tsx

@@ -28,7 +28,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa
   const codeDispatches = useSession((s) => s.codeDispatches)
   const spans = useMemo(() => deriveSpans(nodes), [nodes])
   const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
-  if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无瀑布数据</p></div>
+  if (spans.length === 0) return <div className={css.root}><p className={css.empty}>No timing data</p></div>
   return (
     <>
       <TrajectoryStatsHeader useSession={useSession} />

+ 239 - 51
packages/client/ui-trajectory/src/client/layout.ts

@@ -3,12 +3,16 @@
  * own-duration times, in-flight partial/runningCalls, and group descriptions.
  */
 import type {
+  AssistantBlock,
   AssistantMessageNode,
   CodeSubCall,
   ConversationSnapshot,
   ToolResultNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
-import type { TrajectoryCellProps } from './TrajectoryCell.tsx'
+import type {
+  TrajectoryCellProps,
+  TrajectorySourceBlock,
+} from './trajectory-record.ts'
 
 /** One Message or Step group inside a turn. */
 export interface TrajectoryGroupModel {
@@ -28,6 +32,7 @@ export interface TrajectoryLayoutInput {
   nodes: ConversationSnapshot['nodes']
   partial: ConversationSnapshot['partial']
   runningCalls: ConversationSnapshot['runningCalls']
+  callSchemas?: ConversationSnapshot['callSchemas']
   /** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
   codeDispatches: ConversationSnapshot['codeDispatches']
 }
@@ -52,8 +57,17 @@ interface LaidCell {
  * @returns turns ordered by first appearance.
  */
 export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
-  const { nodes, partial, runningCalls, codeDispatches } = input
+  const { nodes, partial, runningCalls, callSchemas, codeDispatches } = input
   const resultByCall = indexResults(nodes)
+  const callStartById = new Map<string, number>()
+  for (const result of resultByCall.values()) {
+    const startedAt = finiteTime(result.callTime)
+    if (startedAt !== null) callStartById.set(result.callId, startedAt)
+  }
+  for (const call of runningCalls) {
+    const startedAt = finiteTime(call.time)
+    if (startedAt !== null) callStartById.set(call.callId, startedAt)
+  }
   const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
   let index = 0
   let prevAbsTime: number | null = null
@@ -92,14 +106,21 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
         absTime: finiteTime(node.time),
         cell: {
           index: ++index, kind: 'user', text: summarizeContent(node.content),
+          opensTurn: node.kind === 'user',
+          inputDetail: detailContent(node.content),
+          sourceBlocks: node.content.map(block => sourceBlock(block)),
           timeSeconds: 0,
+          startedAt: finiteTime(node.time),
         },
       })
       prevAbsTime = finiteTime(node.time) ?? prevAbsTime
       continue
     }
     if (node.kind === 'assistant') {
-      const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches)
+      const laidList = withSubCalls(
+        expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
+        codeDispatches,
+      )
       for (const laid of laidList) {
         if (node.step > 0) pushStep(node.turn, node.step, laid)
         else pushMessage(node.turn, laid)
@@ -128,7 +149,14 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
             text: node.call !== null
               ? summarizeCall(node.call.name, node.call.argsRaw)
               : summarizeResult(node),
+            ...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
+            outputDetail: detailResult(node),
+            outputBlocks: node.content.map(block => sourceBlock(block)),
+            result: summarizeResult(node),
+            callId: node.callId,
+            isError: node.isError,
             timeSeconds: durationSeconds(node.time, node.callTime),
+            startedAt: finiteTime(node.callTime),
           },
         })
         for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
@@ -145,7 +173,14 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
       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 })
+    const laidList = expandAssistant(
+      fake,
+      index + 1,
+      prevAbsTime,
+      resultByCall,
+      callStartById,
+      { streaming: true },
+    )
     for (const laid of laidList) {
       if (partial.step > 0) pushStep(partial.turn, partial.step, laid)
       else pushMessage(partial.turn, laid)
@@ -165,7 +200,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
         index: ++index,
         kind: 'tool',
         text: summarizeCall(call.name, call.argsRaw),
+        inputDetail: call.argsRaw,
+        callId: call.callId,
         timeSeconds: null,
+        startedAt: finiteTime(call.time),
       },
     })
     for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
@@ -191,11 +229,28 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
     turns.set(1, first)
   }
 
+  for (const entry of turns.values()) {
+    for (const laid of entry.message) attachToolSchema(laid, callSchemas)
+    for (const laid of entry.steps.values()) {
+      for (const cell of laid) attachToolSchema(cell, callSchemas)
+    }
+  }
+
   return [...turns.entries()]
     .sort(([a], [b]) => a - b)
     .map(([turn, entry]) => toTurnModel(turn, entry))
 }
 
+function attachToolSchema(
+  laid: LaidCell,
+  callSchemas: ConversationSnapshot['callSchemas'],
+): void {
+  if (laid.callId === undefined || callSchemas === undefined) return
+  const schema = callSchemas.get(laid.callId)
+  if (schema === undefined) return
+  laid.cell.schemaDetail = JSON.stringify(schema, null, 2)
+}
+
 function toTurnModel(
   turn: number,
   entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> },
@@ -267,8 +322,8 @@ function durationSeconds(later: number, earlier: number | null): number | null {
 }
 
 /** Epoch-ms usable as an absolute time, else null. */
-function finiteTime(time: number): number | null {
-  return Number.isFinite(time) ? time : null
+function finiteTime(time: number | null | undefined): number | null {
+  return typeof time === 'number' && Number.isFinite(time) ? time : null
 }
 
 function expandAssistant(
@@ -276,6 +331,7 @@ function expandAssistant(
   startIndex: number,
   prevAbsTime: number | null,
   results: Map<string, ToolResultNode>,
+  callStarts: ReadonlyMap<string, number>,
   opts?: { streaming?: boolean },
 ): LaidCell[] {
   const out: LaidCell[] = []
@@ -284,56 +340,153 @@ function expandAssistant(
   const streaming = opts?.streaming === true
   const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime)
   const nodeAbs = streaming ? null : finiteTime(node.time)
-  let usageAttached = false
+  const messageText = node.blocks
+    .filter(block => block.kind === 'text' && (!streaming || block.text !== ''))
+    .map(block => block.kind === 'text' ? block.text : '')
+    .join('\n\n')
+  const thinkingText = node.blocks
+    .filter(block => block.kind === 'reasoning' && (!streaming || block.text !== ''))
+    .map(block => block.kind === 'reasoning' ? block.text : '')
+    .join('\n\n')
+  const message: TrajectoryCellProps = {
+    index: ++index,
+    kind: 'message',
+    text: messageText !== ''
+      ? summarizeText(messageText)
+      : thinkingText !== ''
+        ? summarizeText(thinkingText)
+        : summarizeAssistantActivity(node.blocks),
+    ...(messageText !== '' ? { outputDetail: messageText } : {}),
+    ...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}),
+    sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)),
+    timeSeconds: messageDuration,
+    startedAt: finiteTime(node.timing?.stepStartTime),
+  }
+  attachUsage(message, usage)
+  message.assistantMetrics = {
+    timingRecorded: node.timing !== undefined,
+    stepStartTime: node.timing?.stepStartTime ?? null,
+    firstTokenTime: node.timing?.firstTokenTime ?? null,
+    completedTime: streaming ? null : finiteTime(node.time),
+    usageProvided: usage !== undefined,
+    outputTokens: Number.isFinite(usage?.outputTokens) ? usage?.outputTokens ?? null : null,
+  }
+  out.push({ absTime: nodeAbs, cell: message })
 
   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) {
-        attachUsage(cell, usage)
-        usageAttached = usage !== undefined
-      }
-      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,
+    // Text and reasoning belong to the one Assistant record emitted above.
+    if (block.kind !== 'tool-call') continue
+    const result = results.get(block.callId)
+    const toolDuration = streaming || result === undefined
+      ? null
+      : durationSeconds(result.time, result.callTime)
+    const callAbs = finiteTime(callStarts.get(block.callId))
+    out.push({
+      absTime: callAbs,
+      toolName: block.name,
+      callId: block.callId,
+      cell: {
+        index: ++index, kind: 'tool',
+        text: summarizeCall(block.name, block.argsRaw),
+        inputDetail: block.argsRaw,
         callId: block.callId,
-        cell: {
-          index: ++index, kind: 'tool',
-          text: summarizeCall(block.name, block.argsRaw),
-          timeSeconds: toolDuration,
-        },
-      })
-    }
+        ...(result !== undefined
+          ? {
+            outputDetail: detailResult(result),
+            outputBlocks: result.content.map(block => sourceBlock(block)),
+            result: summarizeResult(result),
+            isError: result.isError,
+          }
+          : {}),
+        timeSeconds: toolDuration,
+        startedAt: callAbs,
+      },
+    })
   }
+  return out
+}
 
-  if (out.length === 0 && !streaming) {
-    // Reasoning-only / empty success still owns provider usage on the Message row.
-    const cell: TrajectoryCellProps = {
-      index: ++index, kind: 'message', text: '', timeSeconds: messageDuration,
+function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
+  const tools = new Map<string, number>()
+  for (const block of blocks) {
+    if (block.kind !== 'tool-call') continue
+    tools.set(block.name, (tools.get(block.name) ?? 0) + 1)
+  }
+  if (tools.size > 0) {
+    return 'Tool call only'
+  }
+  return ''
+}
+
+function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
+  switch (block.kind) {
+    case 'text': return { type: 'text', content: block.text }
+    case 'reasoning': return { type: 'thinking', content: block.text }
+    case 'tool-call': return {
+      type: 'tool-call',
+      content: block.argsRaw,
+      callId: block.callId,
+      toolName: block.name,
     }
-    attachUsage(cell, usage)
-    out.push({ absTime: nodeAbs, cell })
+    case 'other': return sourceBlock(block.block)
   }
-  return out
+}
+
+function sourceBlock(value: unknown): TrajectorySourceBlock {
+  if (typeof value !== 'object' || value === null) {
+    return { type: 'unknown', content: stringifySourceValue(value) }
+  }
+  const block = value as Record<string, unknown>
+  const type = typeof block.type === 'string' ? block.type : 'unknown'
+  if (typeof block.text === 'string') {
+    return { type: type === 'reasoning' ? 'thinking' : type, content: block.text }
+  }
+  const imageSrc = sourceImage(block)
+  const imageAlt = typeof block.alt === 'string' ? block.alt : undefined
+  return {
+    type,
+    content: imageSrc === undefined ? stringifySourceValue(value) : '',
+    ...(imageSrc !== undefined ? { imageSrc } : {}),
+    ...(imageAlt !== undefined ? { imageAlt } : {}),
+  }
+}
+
+function sourceImage(block: Record<string, unknown>): string | undefined {
+  if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined
+  for (const candidate of [block.url, block.image_url]) {
+    if (typeof candidate === 'string') return safeImageSource(candidate)
+  }
+  if (typeof block.data === 'string') {
+    const mediaType = [block.mimeType, block.mediaType, block.media_type]
+      .find((candidate): candidate is string => typeof candidate === 'string')
+      ?? 'image/png'
+    return safeImageSource(
+      block.data.startsWith('data:')
+        ? block.data
+        : `data:${mediaType};base64,${block.data}`,
+    )
+  }
+  if (typeof block.source !== 'object' || block.source === null) return undefined
+  const source = block.source as Record<string, unknown>
+  if (typeof source.url === 'string') return safeImageSource(source.url)
+  if (typeof source.data !== 'string') return undefined
+  const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png'
+  return safeImageSource(`data:${mediaType};base64,${source.data}`)
+}
+
+function safeImageSource(value: string): string | undefined {
+  if (value.startsWith('data:image/') || value.startsWith('blob:')) return value
+  try {
+    const protocol = new URL(value).protocol
+    return protocol === 'http:' || protocol === 'https:' ? value : undefined
+  } catch {
+    return undefined
+  }
+}
+
+function stringifySourceValue(value: unknown): string {
+  const json = JSON.stringify(value, null, 2)
+  return json ?? String(value)
 }
 
 /**
@@ -433,12 +586,27 @@ function expandSubCalls(
       cell: {
         index: ++index,
         kind: 'subtool',
+        callId: sub.callId,
         text: settled
           ? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub))
           : summarizeCall(sub.name, sub.argsRaw),
+        ...(settled
+          ? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {})
+          : { inputDetail: sub.argsRaw }),
+        ...(settled
+          ? {
+            outputDetail: detailResult(sub),
+            outputBlocks: sub.content.map(block => sourceBlock(block)),
+            result: summarizeResult(sub),
+            isError: sub.isError,
+          }
+          : {}),
         // 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,
+        startedAt: settled
+          ? finiteTime(sub.callTime)
+          : finiteTime(sub.time),
       },
     })
   }
@@ -448,8 +616,7 @@ function expandSubCalls(
 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}`
+  return `${name} · ${args}`
 }
 
 function summarizeResult(node: ToolResultNode): string {
@@ -464,6 +631,27 @@ function summarizeResult(node: ToolResultNode): string {
   return node.call?.name ?? node.callId
 }
 
+function detailResult(node: ToolResultNode): string {
+  if (node.isError) {
+    return node.error === undefined
+      ? 'error'
+      : `${node.error.name}: ${node.error.code}`
+  }
+  const text = node.content
+    .filter(block => block.type === 'text' && typeof block.text === 'string')
+    .map(block => block.type === 'text' ? block.text : '')
+    .join('\n')
+  if (text !== '') return text
+  return JSON.stringify(node.content, null, 2)
+}
+
+function detailContent(content: readonly { type: string; text?: string }[]): string {
+  return content
+    .filter(block => block.type === 'text' && typeof block.text === 'string')
+    .map(block => block.text ?? '')
+    .join('\n')
+}
+
 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)

+ 81 - 0
packages/client/ui-trajectory/src/client/trajectory-record.ts

@@ -0,0 +1,81 @@
+/** Shared trajectory record data and formatting contracts. */
+
+import type { HTMLAttributes } from 'react'
+
+/** Closed set of trajectory record kinds. */
+export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool'
+
+/** Recorded inputs needed to derive assistant TTFT and decode throughput. */
+export interface AssistantMetricDetail {
+  timingRecorded: boolean
+  stepStartTime: number | null
+  firstTokenTime: number | null
+  completedTime: number | null
+  usageProvided: boolean
+  outputTokens: number | null
+}
+
+/** One source content block preserved in model order for the details panel. */
+export interface TrajectorySourceBlock {
+  type: string
+  content: string
+  imageSrc?: string
+  imageAlt?: string
+  callId?: string
+  toolName?: string
+}
+
+/** Data and optional presentation attributes for one trajectory record. */
+export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
+  /** 1-based record index shown as `#N`. */
+  index: number
+  kind: TrajectoryCellKind
+  /** Single-line summary; CSS ellipsis when it overflows. */
+  text: string
+  /** Whether this user record opens a new model turn. */
+  opensTurn?: boolean
+  /** Full request/message content for the details panel. */
+  inputDetail?: string
+  /** Full assistant/tool result content for the details panel. */
+  outputDetail?: string
+  /** Full assistant reasoning content for the details panel. */
+  thinkingDetail?: string
+  /** Original message blocks in source order for the details panel. */
+  sourceBlocks?: readonly TrajectorySourceBlock[]
+  /** Original tool result blocks in source order for the details panel. */
+  outputBlocks?: readonly TrajectorySourceBlock[]
+  /** Call-time model-visible tool schema for the details panel. */
+  schemaDetail?: string
+  /** Assistant-only timing and token facts for the details panel. */
+  assistantMetrics?: AssistantMetricDetail
+  /** Tool-only result summary paired with the call in the same record. */
+  result?: string
+  /** Tool call id used to link message source blocks to tool records. */
+  callId?: string
+  /** Tool-only result failure state. */
+  isError?: boolean
+  /** Own duration in seconds, or `null` when no duration is known. */
+  timeSeconds: number | null
+  /** Unix epoch milliseconds when this operation actually started, when known. */
+  startedAt?: number | null
+  /** Message-only prompt token count. */
+  input?: number
+  /** Message-only completion token count. */
+  output?: number
+  /** Message-only reasoning token count. */
+  think?: number
+  /** Whether the legacy standalone cell renders its selection treatment. */
+  selected?: boolean
+}
+
+/**
+ * Format own-duration for the trailing time column.
+ * @param seconds - Duration seconds, or `null` when absent.
+ * @returns `—` when unknown, otherwise a signed seconds label.
+ */
+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`
+}

+ 12 - 5
packages/client/ui-trajectory/src/client/views.module.css

@@ -1,17 +1,24 @@
-/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge;
- * cell content width is capped on the turn body (max 880). */
+/* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */
 .root {
-  overflow-y: auto;
+  --dsh-trajectory-toolbar-height: 48px;
+
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
   height: 100%;
   min-height: 0;
   width: 100%;
   box-sizing: border-box;
   color: var(--dsw-alias-label-primary);
-  background: var(--dsw-specific-sidebar-fill);
+  background: var(--dsw-alias-bg-layer-1);
 }
 
 .empty {
-  padding: 16px;
+  display: grid;
+  flex: 1;
+  margin: 0;
+  padding: 24px;
+  place-items: center;
   color: var(--dsw-alias-label-tertiary);
   font: var(--dsw-font-xs-13);
 }

+ 8 - 7
packages/client/ui-trajectory/tests/layout.spec.tsx

@@ -139,7 +139,7 @@ describe('deriveTrajectoryLayout', () => {
       },
     ] as unknown as ConversationSnapshot['nodes']
     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('3s bash×2')
   })
 
   it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
@@ -161,7 +161,7 @@ describe('deriveTrajectoryLayout', () => {
     expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
   })
 
-  it('keeps usage on the fallback Message row when assistant has no text block', () => {
+  it('keeps usage and a meaningful summary when assistant has no text block', () => {
     const nodes = [
       {
         kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
@@ -172,7 +172,7 @@ describe('deriveTrajectoryLayout', () => {
     const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
     const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
     expect(message).toMatchObject({
-      text: '', input: 11, output: 22, think: 3,
+      text: '仅推理输出', input: 11, output: 22, think: 3,
     })
   })
 
@@ -235,11 +235,12 @@ describe('run_code sub-dispatch cells', () => {
     ]]]) 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'])
+    expect(cells.map((c) => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
+    expect(cells[0]?.text).toBe('请求调用 run_code')
     // 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 })
+    expect(cells.map((c) => c.index)).toEqual([1, 2, 3, 4])
+    expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
+    expect(cells[3]).toMatchObject({ timeSeconds: 0.5 })
   })
 
   it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {

+ 88 - 0
packages/client/ui-trajectory/tests/table.spec.tsx

@@ -0,0 +1,88 @@
+// @vitest-environment jsdom
+/** Trajectory ledger selection, details, status, and fold behavior. */
+
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
+import type { TrajectoryTurnModel } from '../src/client/layout.ts'
+
+afterEach(cleanup)
+
+const TURNS: readonly TrajectoryTurnModel[] = [{
+  turn: 1,
+  groups: [{
+    title: 'Step 1',
+    description: '1.5s bash×2',
+    cells: [
+      {
+        index: 1,
+        kind: 'message',
+        text: 'Checking files',
+        outputDetail: 'Checking files',
+        input: 10,
+        output: 20,
+        think: 5,
+        timeSeconds: 1.5,
+        assistantMetrics: {
+          timingRecorded: true,
+          stepStartTime: 1_000,
+          firstTokenTime: 1_500,
+          completedTime: 2_500,
+          usageProvided: true,
+          outputTokens: 20,
+        },
+      },
+      {
+        index: 2,
+        kind: 'tool',
+        text: 'bash · {"command":"pwd"}',
+        inputDetail: '{"command":"pwd"}',
+        timeSeconds: null,
+      },
+      {
+        index: 3,
+        kind: 'tool',
+        text: 'bash · {"command":"false"}',
+        inputDetail: '{"command":"false"}',
+        outputDetail: 'ToolError: non_zero_exit',
+        result: 'non_zero_exit',
+        isError: true,
+        timeSeconds: 0.2,
+      },
+    ],
+  }],
+}]
+
+describe('TrajectoryTable', () => {
+  it('shows assistant timing facts after keyboard selection', () => {
+    render(<TrajectoryTable turns={TURNS} collapsed={false} />)
+    fireEvent.keyDown(screen.getByRole('row', { name: /记录 1,ASSISTANT/ }), { key: 'Enter' })
+    fireEvent.click(screen.getByRole('tab', { name: '计时' }))
+
+    expect(screen.getByText('500 ms')).toBeTruthy()
+    expect(screen.getByText('1.00 s')).toBeTruthy()
+    expect(screen.getByText('20.0 tok/s')).toBeTruthy()
+  })
+
+  it('keeps running and failure semantics distinct from record roles', () => {
+    const view = render(<TrajectoryTable turns={TURNS} collapsed={false} />)
+    expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()
+    expect(view.container.querySelector('tr[data-kind="tool"][data-error="true"]')).toBeTruthy()
+
+    fireEvent.click(screen.getByRole('row', { name: /记录 2,TOOL/ }))
+    expect(screen.getByText('进行中')).toBeTruthy()
+    fireEvent.click(screen.getByRole('row', { name: /记录 3,TOOL/ }))
+    expect(screen.getByText('失败')).toBeTruthy()
+    fireEvent.click(screen.getByRole('tab', { name: '输出' }))
+    expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy()
+  })
+
+  it('retains the ledger header and record count when collapsed', () => {
+    render(<TrajectoryTable turns={TURNS} collapsed />)
+    expect(screen.getByRole('columnheader', { name: '事件' })).toBeTruthy()
+    expect(screen.queryByRole('columnheader', { name: 'Tokens' })).toBeNull()
+    expect(screen.queryByRole('columnheader', { name: '耗时' })).toBeNull()
+    expect(screen.getByText('3 条记录已收起')).toBeTruthy()
+    expect(screen.queryByRole('row', { name: /记录 1,ASSISTANT/ })).toBeNull()
+  })
+})

+ 34 - 8
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -4,7 +4,7 @@
  * 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, trajectory renders the
- * turn-list chrome (no span stats bar), waterfall keeps in-body stats, and
+ * event-ledger 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'
@@ -169,22 +169,48 @@ describe('plugin registration', () => {
 })
 
 describe('tab switching in ConversationRoot', () => {
-  it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => {
+  it('renders all three tabs, defaults to chat, and switches to the trajectory ledger', async () => {
     const b = await bench()
-    mount(b.slots)
+    const view = 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' }))
     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(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2)
+    expect(screen.getAllByLabelText('Step 1')).toHaveLength(2)
+    expect(screen.getByRole('columnheader', { name: '事件' })).toBeTruthy()
+    expect(screen.getByRole('columnheader', { name: '内容' })).toBeTruthy()
+    expect(screen.queryByRole('columnheader', { name: 'Tokens' })).toBeNull()
+    expect(screen.queryByRole('columnheader', { name: '耗时' })).toBeNull()
+    expect(screen.getByRole('toolbar', { name: '轨迹工具栏' }).textContent).toContain('4 条记录')
+    fireEvent.click(screen.getByRole('button', { name: '收起记录' }))
+    expect(screen.getByText('4 条记录已收起')).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: '展开记录' }))
+    expect(screen.getByRole('row', { name: /记录 1,USER/ })).toBeTruthy()
     expect(screen.queryByTestId('chat-body')).toBeNull()
   })
 
+  it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
+    const b = await bench()
+    mount(b.slots)
+    fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
+
+    fireEvent.keyDown(screen.getByRole('row', { name: /记录 3,TOOL/ }), { key: 'Enter' })
+    expect(screen.getByRole('complementary', { name: '记录详情' })).toBeTruthy()
+    expect(screen.getByText('记录 #3')).toBeTruthy()
+    expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy()
+    expect(screen.getByText('完成')).toBeTruthy()
+
+    fireEvent.click(screen.getByRole('tab', { name: '输入' }))
+    expect(screen.getByText('这条记录没有输入载荷')).toBeTruthy()
+    fireEvent.click(screen.getByRole('tab', { name: '输出' }))
+    expect(screen.getByText('[]')).toBeTruthy()
+
+    fireEvent.click(screen.getByRole('button', { name: '关闭详情' }))
+    expect(screen.queryByRole('complementary', { name: '记录详情' })).toBeNull()
+  })
+
   it('waterfall renders bars and switching back to chat does not collapse it', async () => {
     const b = await bench()
     mount(b.slots)

+ 319 - 0
patches/react-json-view-lite@2.5.0.patch

@@ -0,0 +1,319 @@
+diff --git a/dist/DataRenderer.d.ts b/dist/DataRenderer.d.ts
+index 6838d1c3aa8cea1e77534801bf02b9c11ddad1de..3dc8c6568cc4f174126dad32c33a64b0dc25f198 100644
+--- a/dist/DataRenderer.d.ts
++++ b/dist/DataRenderer.d.ts
+@@ -30,6 +30,8 @@ interface CommonRenderProps {
+     clickToExpandNode: boolean;
+     outerRef: React.RefObject<HTMLDivElement>;
+     beforeExpandChange?: (event: NodeExpandingEvent) => boolean;
++    renderExpandableValue?: (value: Object | Array<any>, expanded: boolean) => React.ReactNode;
++    renderStringValue?: (value: string) => React.ReactNode;
+ }
+ export interface JsonRenderProps<T> extends CommonRenderProps {
+     field?: string;
+diff --git a/dist/index.d.ts b/dist/index.d.ts
+index 23e9bca4586b7ea71e7d016230e33eb444cd1304..c49677664f0f0a10e67be688a0157ef9f0d93d6d 100644
+--- a/dist/index.d.ts
++++ b/dist/index.d.ts
+@@ -16,10 +16,12 @@ export interface Props extends React.AriaAttributes {
+     shouldExpandNode?: (level: number, value: any, field?: string) => boolean;
+     clickToExpandNode?: boolean;
+     beforeExpandChange?: (event: NodeExpandingEvent) => boolean;
++    renderExpandableValue?: (value: Object | Array<any>, expanded: boolean) => React.ReactNode;
++    renderStringValue?: (value: string) => React.ReactNode;
+     compactTopLevel?: boolean;
+ }
+ export declare const defaultStyles: StyleProps;
+ export declare const darkStyles: StyleProps;
+ export declare const allExpanded: () => boolean;
+ export declare const collapseAllNested: (level: number) => boolean;
+-export declare const JsonView: ({ data, style, shouldExpandNode, clickToExpandNode, beforeExpandChange, compactTopLevel, ...ariaAttrs }: Props) => React.JSX.Element;
++export declare const JsonView: ({ data, style, shouldExpandNode, clickToExpandNode, beforeExpandChange, renderExpandableValue, renderStringValue, compactTopLevel, ...ariaAttrs }: Props) => React.JSX.Element;
+diff --git a/dist/index.js b/dist/index.js
+index ab8b98c992bd34e00e7c3a0257d6778d7928dde8..669304fbba450c789bf4a4264f0627a8eec9d178 100644
+--- a/dist/index.js
++++ b/dist/index.js
+@@ -50,7 +50,9 @@ function ExpandableObject(_ref) {
+     shouldExpandNode,
+     clickToExpandNode,
+     outerRef,
+-    beforeExpandChange
++    beforeExpandChange,
++    renderExpandableValue,
++    renderStringValue
+   } = _ref;
+   const shouldExpandNodeCalledRef = React.useRef(false);
+   const [expanded, setExpanded] = React.useState(() => shouldExpandNode(level, value, field));
+@@ -146,7 +148,7 @@ function ExpandableObject(_ref) {
+     onKeyDown: onKeyDown
+   }, quoteString(field, style.quotesForFieldNames), ":")) : (/*#__PURE__*/React.createElement("span", {
+     className: style.label
+-  }, quoteString(field, style.quotesForFieldNames), ":"))), /*#__PURE__*/React.createElement("span", {
++  }, quoteString(field, style.quotesForFieldNames), ":"))), renderExpandableValue ? renderExpandableValue(value, expanded) : /*#__PURE__*/React.createElement("span", {
+     className: style.punctuation
+   }, openBracket), expanded ? (/*#__PURE__*/React.createElement("ul", {
+     id: contentsId,
+@@ -162,16 +164,19 @@ function ExpandableObject(_ref) {
+     shouldExpandNode: shouldExpandNode,
+     clickToExpandNode: clickToExpandNode,
+     beforeExpandChange: beforeExpandChange,
+-    outerRef: outerRef
++    outerRef: outerRef,
++    renderExpandableValue: renderExpandableValue,
++    renderStringValue: renderStringValue
+   }))))) : (
++  renderExpandableValue ? null :
+   /*#__PURE__*/
+   React.createElement("span", {
+     className: style.collapsedContent,
+     onClick: onClick,
+     onKeyDown: onKeyDown
+-  })), /*#__PURE__*/React.createElement("span", {
++  })), renderExpandableValue ? null : /*#__PURE__*/React.createElement("span", {
+     className: style.punctuation
+-  }, closeBracket), !lastElement && /*#__PURE__*/React.createElement("span", {
++  }, closeBracket), !renderExpandableValue && !lastElement && /*#__PURE__*/React.createElement("span", {
+     className: style.punctuation
+   }, ","));
+ }
+@@ -207,7 +212,9 @@ function JsonObject(_ref3) {
+     clickToExpandNode,
+     level,
+     outerRef,
+-    beforeExpandChange
++    beforeExpandChange,
++    renderExpandableValue,
++    renderStringValue
+   } = _ref3;
+   return ExpandableObject({
+     field,
+@@ -221,7 +228,9 @@ function JsonObject(_ref3) {
+     clickToExpandNode,
+     data: Object.keys(value).map(key => [key, value[key]]),
+     outerRef,
+-    beforeExpandChange
++    beforeExpandChange,
++    renderExpandableValue,
++    renderStringValue
+   });
+ }
+ function JsonArray(_ref4) {
+@@ -234,7 +243,9 @@ function JsonArray(_ref4) {
+     shouldExpandNode,
+     clickToExpandNode,
+     outerRef,
+-    beforeExpandChange
++    beforeExpandChange,
++    renderExpandableValue,
++    renderStringValue
+   } = _ref4;
+   return ExpandableObject({
+     field,
+@@ -246,9 +257,11 @@ function JsonArray(_ref4) {
+     style,
+     shouldExpandNode,
+     clickToExpandNode,
+-    data: value.map(element => [undefined, element]),
++    data: value.map((element, index) => [String(index), element]),
+     outerRef,
+-    beforeExpandChange
++    beforeExpandChange,
++    renderExpandableValue,
++    renderStringValue
+   });
+ }
+ function JsonPrimitiveValue(_ref5) {
+@@ -256,7 +269,8 @@ function JsonPrimitiveValue(_ref5) {
+     field,
+     value,
+     style,
+-    lastElement
++    lastElement,
++    renderStringValue
+   } = _ref5;
+   let stringValue;
+   let valueStyle = style.otherValue;
+@@ -291,7 +305,7 @@ function JsonPrimitiveValue(_ref5) {
+     "aria-selected": undefined
+   }, (field || field === '') && (/*#__PURE__*/React.createElement("span", {
+     className: style.label
+-  }, quoteString(field, style.quotesForFieldNames), ":")), /*#__PURE__*/React.createElement("span", {
++  }, quoteString(field, style.quotesForFieldNames), ":")), isString(value) && renderStringValue ? renderStringValue(value) : /*#__PURE__*/React.createElement("span", {
+     className: valueStyle
+   }, stringValue), !lastElement && /*#__PURE__*/React.createElement("span", {
+     className: style.punctuation
+@@ -365,6 +379,8 @@ const JsonView = _ref => {
+     shouldExpandNode = allExpanded,
+     clickToExpandNode = false,
+     beforeExpandChange,
++    renderExpandableValue,
++    renderStringValue,
+     compactTopLevel,
+     ...ariaAttrs
+   } = _ref;
+@@ -390,7 +406,9 @@ const JsonView = _ref => {
+       shouldExpandNode: shouldExpandNode,
+       clickToExpandNode: clickToExpandNode,
+       beforeExpandChange: beforeExpandChange,
+-      outerRef: outerRef
++      outerRef: outerRef,
++      renderExpandableValue: renderExpandableValue,
++      renderStringValue: renderStringValue
+     });
+   }) : (/*#__PURE__*/React.createElement(DataRender, {
+     value: data,
+@@ -403,7 +421,9 @@ const JsonView = _ref => {
+     shouldExpandNode: shouldExpandNode,
+     clickToExpandNode: clickToExpandNode,
+     outerRef: outerRef,
+-    beforeExpandChange: beforeExpandChange
++    beforeExpandChange: beforeExpandChange,
++    renderExpandableValue: renderExpandableValue,
++    renderStringValue: renderStringValue
+   })));
+ };
+ 
+diff --git a/dist/index.modern.js b/dist/index.modern.js
+index e25aea1bc8936b69275b9fbe7011d0466480f99c..f4c509e4459f70080b90695c00f8a17e8f4aac4e 100644
+--- a/dist/index.modern.js
++++ b/dist/index.modern.js
+@@ -50,7 +50,9 @@ function ExpandableObject(_ref) {
+     shouldExpandNode,
+     clickToExpandNode,
+     outerRef,
+-    beforeExpandChange
++    beforeExpandChange,
++    renderExpandableValue,
++    renderStringValue
+   } = _ref;
+   const shouldExpandNodeCalledRef = useRef(false);
+   const [expanded, setExpanded] = useState(() => shouldExpandNode(level, value, field));
+@@ -146,7 +148,7 @@ function ExpandableObject(_ref) {
+     onKeyDown: onKeyDown
+   }, quoteString(field, style.quotesForFieldNames), ":")) : (/*#__PURE__*/createElement("span", {
+     className: style.label
+-  }, quoteString(field, style.quotesForFieldNames), ":"))), /*#__PURE__*/createElement("span", {
++  }, quoteString(field, style.quotesForFieldNames), ":"))), renderExpandableValue ? renderExpandableValue(value, expanded) : /*#__PURE__*/createElement("span", {
+     className: style.punctuation
+   }, openBracket), expanded ? (/*#__PURE__*/createElement("ul", {
+     id: contentsId,
+@@ -162,16 +164,19 @@ function ExpandableObject(_ref) {
+     shouldExpandNode: shouldExpandNode,
+     clickToExpandNode: clickToExpandNode,
+     beforeExpandChange: beforeExpandChange,
+-    outerRef: outerRef
++    outerRef: outerRef,
++    renderExpandableValue: renderExpandableValue,
++    renderStringValue: renderStringValue
+   }))))) : (
++  renderExpandableValue ? null :
+   /*#__PURE__*/
+   createElement("span", {
+     className: style.collapsedContent,
+     onClick: onClick,
+     onKeyDown: onKeyDown
+-  })), /*#__PURE__*/createElement("span", {
++  })), renderExpandableValue ? null : /*#__PURE__*/createElement("span", {
+     className: style.punctuation
+-  }, closeBracket), !lastElement && /*#__PURE__*/createElement("span", {
++  }, closeBracket), !renderExpandableValue && !lastElement && /*#__PURE__*/createElement("span", {
+     className: style.punctuation
+   }, ","));
+ }
+@@ -207,7 +212,9 @@ function JsonObject(_ref3) {
+     clickToExpandNode,
+     level,
+     outerRef,
+-    beforeExpandChange
++    beforeExpandChange,
++    renderExpandableValue,
++    renderStringValue
+   } = _ref3;
+   return ExpandableObject({
+     field,
+@@ -221,7 +228,9 @@ function JsonObject(_ref3) {
+     clickToExpandNode,
+     data: Object.keys(value).map(key => [key, value[key]]),
+     outerRef,
+-    beforeExpandChange
++    beforeExpandChange,
++    renderExpandableValue,
++    renderStringValue
+   });
+ }
+ function JsonArray(_ref4) {
+@@ -234,7 +243,9 @@ function JsonArray(_ref4) {
+     shouldExpandNode,
+     clickToExpandNode,
+     outerRef,
+-    beforeExpandChange
++    beforeExpandChange,
++    renderExpandableValue,
++    renderStringValue
+   } = _ref4;
+   return ExpandableObject({
+     field,
+@@ -246,9 +257,11 @@ function JsonArray(_ref4) {
+     style,
+     shouldExpandNode,
+     clickToExpandNode,
+-    data: value.map(element => [undefined, element]),
++    data: value.map((element, index) => [String(index), element]),
+     outerRef,
+-    beforeExpandChange
++    beforeExpandChange,
++    renderExpandableValue,
++    renderStringValue
+   });
+ }
+ function JsonPrimitiveValue(_ref5) {
+@@ -256,7 +269,8 @@ function JsonPrimitiveValue(_ref5) {
+     field,
+     value,
+     style,
+-    lastElement
++    lastElement,
++    renderStringValue
+   } = _ref5;
+   let stringValue;
+   let valueStyle = style.otherValue;
+@@ -291,7 +305,7 @@ function JsonPrimitiveValue(_ref5) {
+     "aria-selected": undefined
+   }, (field || field === '') && (/*#__PURE__*/createElement("span", {
+     className: style.label
+-  }, quoteString(field, style.quotesForFieldNames), ":")), /*#__PURE__*/createElement("span", {
++  }, quoteString(field, style.quotesForFieldNames), ":")), isString(value) && renderStringValue ? renderStringValue(value) : /*#__PURE__*/createElement("span", {
+     className: valueStyle
+   }, stringValue), !lastElement && /*#__PURE__*/createElement("span", {
+     className: style.punctuation
+@@ -365,6 +379,8 @@ const JsonView = _ref => {
+     shouldExpandNode = allExpanded,
+     clickToExpandNode = false,
+     beforeExpandChange,
++    renderExpandableValue,
++    renderStringValue,
+     compactTopLevel,
+     ...ariaAttrs
+   } = _ref;
+@@ -390,7 +406,9 @@ const JsonView = _ref => {
+       shouldExpandNode: shouldExpandNode,
+       clickToExpandNode: clickToExpandNode,
+       beforeExpandChange: beforeExpandChange,
+-      outerRef: outerRef
++      outerRef: outerRef,
++      renderExpandableValue: renderExpandableValue,
++      renderStringValue: renderStringValue
+     });
+   }) : (/*#__PURE__*/createElement(DataRender, {
+     value: data,
+@@ -403,7 +421,9 @@ const JsonView = _ref => {
+     shouldExpandNode: shouldExpandNode,
+     clickToExpandNode: clickToExpandNode,
+     outerRef: outerRef,
+-    beforeExpandChange: beforeExpandChange
++    beforeExpandChange: beforeExpandChange,
++    renderExpandableValue: renderExpandableValue,
++    renderStringValue: renderStringValue
+   })));
+ };
+ 

+ 28 - 0
pnpm-lock.yaml

@@ -4,6 +4,9 @@ settings:
   autoInstallPeers: true
   excludeLinksFromLockfile: false
 
+patchedDependencies:
+  react-json-view-lite@2.5.0: 8caeb240cf32d6060a32781982025430c919fe9eba857054195179dd08acff5e
+
 importers:
 
   .:
@@ -994,12 +997,24 @@ importers:
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
+      mdast-util-from-markdown:
+        specifier: ^2.0.3
+        version: 2.0.3
+      mdast-util-gfm:
+        specifier: ^3.1.0
+        version: 3.1.0
+      micromark-extension-gfm:
+        specifier: ^3.0.0
+        version: 3.0.0
       react:
         specifier: ^18.2.0
         version: 18.3.1
       react-dom:
         specifier: ^18.2.0
         version: 18.3.1(react@18.3.1)
+      react-json-view-lite:
+        specifier: ^2.5.0
+        version: 2.5.0(patch_hash=8caeb240cf32d6060a32781982025430c919fe9eba857054195179dd08acff5e)(react@18.3.1)
       react-markdown:
         specifier: ^10.1.0
         version: 10.1.0(@types/react@18.3.31)(react@18.3.1)
@@ -1279,6 +1294,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-conversation':
         specifier: workspace:^
         version: link:../ui-conversation
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
@@ -9283,6 +9301,12 @@ packages:
   react-is@17.0.2:
     resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
 
+  react-json-view-lite@2.5.0:
+    resolution: {integrity: sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      react: ^18.0.0 || ^19.0.0
+
   react-markdown@10.1.0:
     resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
     peerDependencies:
@@ -14434,6 +14458,10 @@ snapshots:
 
   react-is@17.0.2: {}
 
+  react-json-view-lite@2.5.0(patch_hash=8caeb240cf32d6060a32781982025430c919fe9eba857054195179dd08acff5e)(react@18.3.1):
+    dependencies:
+      react: 18.3.1
+
   react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1):
     dependencies:
       '@types/hast': 3.0.5

+ 3 - 0
pnpm-workspace.yaml

@@ -54,3 +54,6 @@ minimumReleaseAgeExclude:
   # Fresh pi-ai releases carry the model catalog updates that are the whole
   # point of bumping it; waiting out the release age would defeat that.
   - '@earendil-works/pi-ai@0.81.1'
+
+patchedDependencies:
+  react-json-view-lite@2.5.0: patches/react-json-view-lite@2.5.0.patch