Browse Source

Merge pull request #3880 from deepseek-harness/xtr/trajectory-json-string-wrapping

feat(client): improve trajectory JSON and PTC inspection
_Kerman 3 weeks ago
parent
commit
d3856902b6
53 changed files with 1818 additions and 312 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml
  5. 29 0
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md
  6. 29 0
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md
  7. 6 1
      apps/web/tests/lifecycle-chrome.e2e.ts
  8. 22 3
      apps/web/tests/navigation-panes.e2e.ts
  9. 44 1
      apps/web/tests/ptc-round.e2e.ts
  10. 2 2
      apps/web/tests/turn-tail-actions.e2e.ts
  11. 0 2
      packages/client/locale/src/locales/en.ts
  12. 0 2
      packages/client/locale/src/locales/zh.ts
  13. 2 2
      packages/client/ui-chat/README.i18n.yaml
  14. 2 0
      packages/client/ui-chat/README.md
  15. 2 0
      packages/client/ui-chat/README.zh.md
  16. 8 1
      packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx
  17. 10 3
      packages/client/ui-chat/src/client/chat/ReasoningRow.tsx
  18. 21 2
      packages/client/ui-chat/src/client/conversation-nodes/assistant.ts
  19. 9 2
      packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
  20. 29 2
      packages/client/ui-chat/tests/reasoning-row.client.spec.tsx
  21. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  22. 3 1
      packages/client/ui-primitives/README.md
  23. 3 1
      packages/client/ui-primitives/README.zh.md
  24. 141 18
      packages/client/ui-primitives/src/JsonTree.module.css
  25. 349 141
      packages/client/ui-primitives/src/JsonTree.tsx
  26. 8 0
      packages/client/ui-primitives/src/icons/index.tsx
  27. 7 3
      packages/client/ui-primitives/src/markdown/CodeBlock.tsx
  28. 2 2
      packages/client/ui-primitives/tests/icons.client.spec.tsx
  29. 159 31
      packages/client/ui-primitives/tests/json-tree.client.spec.tsx
  30. 2 2
      packages/client/ui-trajectory/README.i18n.yaml
  31. 4 0
      packages/client/ui-trajectory/README.md
  32. 4 0
      packages/client/ui-trajectory/README.zh.md
  33. 1 0
      packages/client/ui-trajectory/src/client/TrajectoryCell.tsx
  34. 161 8
      packages/client/ui-trajectory/src/client/TrajectoryTable.module.css
  35. 314 61
      packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
  36. 8 1
      packages/client/ui-trajectory/src/client/TrajectoryView.tsx
  37. 62 0
      packages/client/ui-trajectory/src/client/code-program.ts
  38. 6 0
      packages/client/ui-trajectory/src/client/index.ts
  39. 2 1
      packages/client/ui-trajectory/src/client/layout.ts
  40. 15 1
      packages/client/ui-trajectory/src/client/locales.ts
  41. 13 0
      packages/client/ui-trajectory/src/client/string-wrapping-store.ts
  42. 19 2
      packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts
  43. 2 0
      packages/client/ui-trajectory/src/client/trajectory-record.ts
  44. 116 0
      packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx
  45. 70 0
      packages/client/ui-trajectory/tests/code-program.client.spec.ts
  46. 9 2
      packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
  47. 26 0
      packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts
  48. 39 5
      packages/client/ui-trajectory/tests/table.client.spec.tsx
  49. 1 1
      packages/client/ui-trajectory/tests/views.client.spec.tsx
  50. 4 2
      snapshots/web/auto-review-denial/ui.expected.md
  51. 12 0
      snapshots/web/navigation-panes/timing.expected.md
  52. 7 0
      snapshots/web/ptc-round/code.expected.md
  53. 22 0
      snapshots/web/ptc-round/trajectory.expected.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md
-2026-09-06-embedded-stream-record-readers.md: 972fee634833cef5fd7b0a54f69780b9370f0cc3
-2026-09-06-embedded-stream-record-readers.zh.md: faba6e179887a9943926f2c73aa8e42a10cee300
+2026-09-06-embedded-stream-record-readers.md: 76e109de577d070093bb7123aa50d9a4ae7fcbe5
+2026-09-06-embedded-stream-record-readers.zh.md: 33f4b31117197c47e7f2ff1637bc13fa2cbf1a8c

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md

@@ -20,7 +20,7 @@ After v2 embedded streams settlement widened with the message content and Chat a
 - Run readers: `runFirstTokenTime` and `runFirstVisibleTime` reconstruct the first qualifying member's time from `time0` and the `dt` gaps and stop scanning there; a name-bearing Tool-call run yields `time0` without reading a fragment.
 - Run readers: `runFirstTokenTime` and `runFirstVisibleTime` reconstruct the first qualifying member's time from `time0` and the `dt` gaps and stop scanning there; a name-bearing Tool-call run yields `time0` without reading a fragment.
 - Stream readers: `assistantStreamFirstTokenTime`, `assistantStreamHasVisibleContent`, `assistantStreamHasVisibleText`, `lastAssistantStreamChunk(stream, type)` (backward scan), `assistantStreamChunks(stream, type)`, `joinAssistantStreamText`, and `assembleAssistantStream`, which feeds a `BlockAssembler` one joined delta per run (assembly only concatenates, so blocks, usage, finish, and replay state equal the per-member result). `RawStreamChunkType` excludes the delta types, so a raw-chunk lookup can never silently skip packed members.
 - Stream readers: `assistantStreamFirstTokenTime`, `assistantStreamHasVisibleContent`, `assistantStreamHasVisibleText`, `lastAssistantStreamChunk(stream, type)` (backward scan), `assistantStreamChunks(stream, type)`, `joinAssistantStreamText`, and `assembleAssistantStream`, which feeds a `BlockAssembler` one joined delta per run (assembly only concatenates, so blocks, usage, finish, and replay state equal the per-member result). `RawStreamChunkType` excludes the delta types, so a raw-chunk lookup can never silently skip packed members.
 
 
-Session Stats reads `assistantStreamFirstTokenTime`; the token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images.
+Session Stats, Chat, and Trajectory read `assistantStreamFirstTokenTime` from both `assistant/attempt` and `assistant/message`, retaining the Step's first token across retries. Chat and Trajectory settle content from the assembled message while reading timing independently, so reopening history retains TTFT and decoding metrics without expanding streams. The token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images.
 
 
 `expandAssistantStream` keeps its strict validation and its remaining callers, which need every member or validate the stream at a durable boundary: Session restore validation, the v1-to-v2 migration validator and publication Worker replay, the reconnect baseline, and test support.
 `expandAssistantStream` keeps its strict validation and its remaining callers, which need every member or validate the stream at a durable boundary: Session restore validation, the v1-to-v2 migration validator and publication Worker replay, the reconnect baseline, and test support.
 
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md

@@ -20,7 +20,7 @@ Session 格式 v2 将每次模型尝试的紧凑流(`AssistantStreamRecord[]`
 - Run 读取器:`runFirstTokenTime` 与 `runFirstVisibleTime` 从 `time0` 与 `dt` 间隔重建首个合格成员的时间并停止扫描;带名称的 Tool-call run 直接产出 `time0`,不读片段。
 - Run 读取器:`runFirstTokenTime` 与 `runFirstVisibleTime` 从 `time0` 与 `dt` 间隔重建首个合格成员的时间并停止扫描;带名称的 Tool-call run 直接产出 `time0`,不读片段。
 - 流读取器:`assistantStreamFirstTokenTime`、`assistantStreamHasVisibleContent`、`assistantStreamHasVisibleText`、`lastAssistantStreamChunk(stream, type)`(逆向扫描)、`assistantStreamChunks(stream, type)`、`joinAssistantStreamText` 与 `assembleAssistantStream`(每个 run 向 `BlockAssembler` 喂入一个拼接后的 delta;组装只做拼接,因此 blocks、usage、finish 与 replay state 与逐成员结果一致)。`RawStreamChunkType` 排除 delta 类型,因此原始 chunk 查找不可能静默跳过打包成员。
 - 流读取器:`assistantStreamFirstTokenTime`、`assistantStreamHasVisibleContent`、`assistantStreamHasVisibleText`、`lastAssistantStreamChunk(stream, type)`(逆向扫描)、`assistantStreamChunks(stream, type)`、`joinAssistantStreamText` 与 `assembleAssistantStream`(每个 run 向 `BlockAssembler` 喂入一个拼接后的 delta;组装只做拼接,因此 blocks、usage、finish 与 replay state 与逐成员结果一致)。`RawStreamChunkType` 排除 delta 类型,因此原始 chunk 查找不可能静默跳过打包成员。
 
 
-Session Stats 读取 `assistantStreamFirstTokenTime`;token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。
+Session Stats、Chat 与 Trajectory 从 `assistant/attempt` 和 `assistant/message` 读取 `assistantStreamFirstTokenTime`,跨重试保留步骤的首个 token。Chat 与 Trajectory 从组装后的消息结算内容,并独立读取计时,因此重新打开历史时无需展开流便能保留 TTFT 与解码指标。token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。
 
 
 `expandAssistantStream` 保留其严格校验与其余调用方(需要每个成员或在持久边界校验流):Session 恢复校验、v1-to-v2 迁移校验器与发布 Worker 重放、重连基线、测试支撑。
 `expandAssistantStream` 保留其严格校验与其余调用方(需要每个成员或在持久边界校验流):Session 恢复校验、v1-to-v2 迁移校验器与发布 Worker 重放、重连基线、测试支撑。
 
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.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-09-09-ptc-trajectory-code-inspection.md
+2026-09-09-ptc-trajectory-code-inspection.md: 477d199f519b5d515e5d58430bd902d9d209e46b
+2026-09-09-ptc-trajectory-code-inspection.zh.md: f305569176214c63ac549b0ec5103289e3e57a88

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md

@@ -0,0 +1,29 @@
+# Agent Note: Inspect recorded PTC source in Trajectory
+
+Status: implemented
+
+English | [中文](2026-09-09-ptc-trajectory-code-inspection.zh.md)
+
+## Problem
+
+PTC programs arrive as JSON string arguments. Escaping makes long programs difficult to read in a generic argument tree, while historical calls may use a different runtime language from the current deployment. Recorded result text can contain both printed output and a returned value without retaining their separation.
+
+## Decision
+
+[Trajectory](../../../../packages/client/ui-trajectory/README.md) identifies calls by their recorded tool name and derives a code inspector from validated `run_code` arguments. The validated program carries the original JSON text; copying preserves source and argument bytes and exposes the original JSON through a separate toggle. Syntax highlighting requires an unambiguous TypeScript or Python hint in that call's recorded parameter description; unknown or conflicting hints leave plain text. Unsupported arguments retain the generic inspector.
+
+The result view preserves recorded text and uses a tree only for complete JSON objects or arrays. Errors retain captured output. Code views sample the shared wrapping preference when opened and keep their own choice while mounted.
+
+The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlement; the [client presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md) still owns deriving UI from recorded facts. This inspector adds no Session events or host presentation fields.
+
+## Alternatives considered
+
+**Keep source inside the argument tree.** JSON escaping obscures program structure and makes copying executable source cumbersome.
+
+**Use the active runtime language or infer it from source.** Either can mislabel a historical program. Recorded schema hints constrain highlighting without changing the recording.
+
+**Split printed output from return values.** Historical rendered text does not establish that distinction; a parser could invent a separation the producer never recorded.
+
+## Consequences
+
+Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Component tests cover recorded-name recognition, schema fallback, exact source and argument copying, output states, and independent wrapping. JSON-tree tests cover clipping geometry, missing `ResizeObserver`, clipboard settlement after hover changes or unmount, and value-read counts during hover. Thinking tests cover body arrival, manual disclosure, and switching records; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 在 Trajectory 中检查已记录的 PTC 源码
+
+Status: implemented
+
+[English](2026-09-09-ptc-trajectory-code-inspection.md) | 中文
+
+## 问题
+
+PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树中难以阅读,而历史调用使用的运行时语言可能不同于当前部署。记录的结果文本可以同时包含打印输出与返回值,却未保留二者的分界。
+
+## 决策
+
+[Trajectory](../../../../packages/client/ui-trajectory/README.zh.md) 按已记录的工具名识别调用,并从已验证的 `run_code` 参数派生代码检查器。验证后的程序携带原始 JSON 文本;复制时保留源码与参数字节,并通过独立切换按钮展示原始 JSON。语法高亮要求该调用记录的参数说明包含明确且无冲突的 TypeScript 或 Python 提示;未知或冲突的提示使用纯文本。不支持的参数保留通用检查器。
+
+结果视图保留记录的文本,只对完整 JSON 对象或数组使用树形展示。错误保留已捕获的输出。代码视图在打开时读取共享换行偏好,并在挂载期间保留自身的选择。
+
+[PTC 运行时决策](2026-06-15-ptc.zh.md) 仍负责执行与结算;[客户端展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md) 仍负责从已记录事实派生 UI。此检查器不增加 Session 事件或宿主展示字段。
+
+## 考虑过的替代方案
+
+**把源码保留在参数树中。** JSON 转义遮蔽程序结构,也使复制可执行源码变得繁琐。
+
+**使用当前运行时语言,或从源码推断语言。** 两种方式都可能错误标注历史程序。已记录 Schema 的提示约束高亮,无需修改记录。
+
+**拆分打印输出与返回值。** 历史渲染文本无法确定二者的分界;解析器可能凭空添加生产方从未记录的分隔。
+
+## 后果
+
+读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。组件测试覆盖记录工具名识别、Schema 回退、源码与参数原样复制、输出状态及独立换行。JSON 树测试覆盖裁剪几何、缺少 `ResizeObserver`、悬停切换或卸载后剪贴板写入落定,以及悬停期间读取值的次数。思考测试覆盖正文到达、手动展开折叠及记录切换;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。

+ 6 - 1
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -290,7 +290,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       try {
       try {
         await input.press('Enter')
         await input.press('Enter')
         if (MODE !== 'record') {
         if (MODE !== 'record') {
-          const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
+          const thinking = page.locator('[data-variant="think"][data-state="running"]')
+          const disclosure = thinking.getByRole('button')
+          await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
+          await disclosure.click()
+          await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
+          const liveTail = thinking.locator('[data-follow-end]')
           await expect.poll(async () => {
           await expect.poll(async () => {
             if (await liveTail.count() !== 1) return false
             if (await liveTail.count() !== 1) return false
             return await liveTail.evaluate((element) => {
             return await liveTail.evaluate((element) => {

+ 22 - 3
apps/web/tests/navigation-panes.e2e.ts

@@ -24,6 +24,7 @@ import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './supp
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url))
 const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
+const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md')
 const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
 const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
 const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
@@ -97,7 +98,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
       const raw = await readFile(SEED, 'utf8')
       const raw = await readFile(SEED, 'utf8')
       expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts')
       expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts')
         .toEqual([PROMPT_TURN1, PROMPT_TURN2])
         .toEqual([PROMPT_TURN1, PROMPT_TURN2])
-      await seedSession(scaffold, raw, SEED_ID)
+      // The inspector's calendar date must not depend on the day the test runs.
+      await seedSession(scaffold, raw, SEED_ID, undefined, { createdAt: Date.UTC(2026, 0, 1) })
     }
     }
     browser = await chromium.launch()
     browser = await chromium.launch()
   }, 120_000)
   }, 120_000)
@@ -264,13 +266,30 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
     await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
     await page.getByRole('tab', { name: 'Result' }).click()
     await page.getByRole('tab', { name: 'Result' }).click()
     await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
     await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
-    expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBe(0)
+    expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBeGreaterThan(0)
     const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
     const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
       .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
     await details.getByRole('button', { name: 'Close details' }).click()
     await details.getByRole('button', { name: 'Close details' }).click()
   }, 60_000)
   }, 60_000)
 
 
+  it.skipIf(MODE === 'record')('restores Assistant timing from recorded history', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timing'))
+    await ensureSeedOpen(page)
+    await page.getByRole('tab', { name: 'Trajectory' }).click()
+    await page.getByRole('button', { name: 'Request #1', exact: true }).click()
+    const details = page.getByRole('complementary', { name: 'Event details' })
+    await details.getByRole('tab', { name: 'Timing', exact: true }).click()
+    const panel = details.getByRole('tabpanel', { name: 'Timing' })
+    for (const metric of ['TTFT', 'Generation', 'Throughput']) {
+      const value = panel.getByText(metric, { exact: true }).locator('..').locator('dd')
+      await expect.poll(() => value.textContent()).toMatch(/^\d/)
+    }
+    expect(await panel.getByText('First token unavailable', { exact: true }).count()).toBe(0)
+    const snapshot = await captureStableAria(page, '#trajectory-detail-panel', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(TIMING_EXPECTED, snapshot, MODE)
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => {
   it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
     await ensureSeedOpen(page)
     await ensureSeedOpen(page)
@@ -504,7 +523,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
   it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
   it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
       'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
-      'terminal-card.expected.md',
+      'terminal-card.expected.md', 'timing.expected.md',
     ])
     ])
   })
   })
 })
 })

+ 44 - 1
apps/web/tests/ptc-round.e2e.ts

@@ -8,13 +8,15 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
 import {
-  acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts,
+  acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url))
+const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url))
+const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 
 
 // Elicits the successful and failed sub-rows this scenario asserts.
 // Elicits the successful and failed sub-rows this scenario asserts.
@@ -151,6 +153,47 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
   })
   })
 
 
+  it.skipIf(MODE === 'record')('inspects recorded PTC source, wrapping, and original JSON in the trajectory', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-inspector'))
+    const call = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'run_code')
+    if (call?.type !== 'tool/call') throw new Error('recorded PTC call missing')
+    const args = JSON.parse(call.data.arguments) as { code: string; description: string }
+    await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+    const row = page.locator('tr[data-kind="tool"]').filter({ hasText: 'run_code' }).first()
+    await row.click()
+    await page.getByRole('tab', { name: 'Code', exact: true }).waitFor()
+    expect(await page.getByRole('tabpanel').textContent()).toContain(args.description)
+    expect(await page.getByRole('tabpanel').locator('dl').first().locator('dt').allTextContents())
+      .toEqual(['Hierarchy', 'Status'])
+    const overview = await Promise.all([1, 2].map(index => captureStableAria(
+      page, `[role="tabpanel"] [class*="overviewSections"] > section:nth-child(${index})`, scaffold.workspaceCwd,
+    )))
+    await compareOrRefreshGolden(TRAJECTORY_EXPECTED, overview.join('\n'), MODE)
+
+    await page.getByRole('button', { name: 'Code', exact: true }).click()
+    const source = page.locator('[data-line-numbers] pre')
+    await source.waitFor()
+    expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code)
+    const wrap = page.getByRole('button', { name: 'Wrap lines', exact: true })
+    expect(await wrap.getAttribute('aria-pressed')).toBe('false')
+    const content = page.locator('[data-wrap]').filter({ has: source })
+    expect(await content.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true)
+    await wrap.click()
+    expect(await wrap.getAttribute('aria-pressed')).toBe('true')
+    await expect.poll(() => content.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1)
+    const code = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(CODE_EXPECTED, code, MODE)
+
+    await page.getByRole('button', { name: 'Original JSON' }).click()
+    const json = page.getByRole('tree', { name: 'parameters JSON' })
+    await json.waitFor()
+    expect(await json.textContent()).toContain(args.description)
+    await page.getByRole('button', { name: 'Original JSON' }).click()
+    expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code)
+    await page.getByRole('tab', { name: 'Summary', exact: true }).click()
+    expect(await wrap.getAttribute('aria-pressed')).toBe('true')
+  })
+
   it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => {
   it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])

+ 2 - 2
apps/web/tests/turn-tail-actions.e2e.ts

@@ -201,8 +201,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     await timeTrigger.click()
     await timeTrigger.click()
     const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
     const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
     expect(await timeDialog.count()).toBe(1)
     expect(await timeDialog.count()).toBe(1)
-    expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
-    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
+    expect(await timeDialog.getByText(/tok\/s/).count()).toBeGreaterThan(0)
+    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(1)
     await page.keyboard.press('Escape')
     await page.keyboard.press('Escape')
     await trigger.click()
     await trigger.click()
 
 

+ 0 - 2
packages/client/locale/src/locales/en.ts

@@ -34,8 +34,6 @@ export const en = {
   'unknown': 'Unknown',
   'unknown': 'Unknown',
   'none': 'None',
   'none': 'None',
   'truncated': 'Truncated',
   'truncated': 'Truncated',
-  'json.collapseNode': 'Collapse JSON node',
-  'json.expandNode': 'Expand JSON node',
   'json.label': 'JSON',
   'json.label': 'JSON',
   'markdown.footnotes': 'Footnotes',
   'markdown.footnotes': 'Footnotes',
   'markdown.truncatedCharacters': '… truncated at {total} characters',
   'markdown.truncatedCharacters': '… truncated at {total} characters',

+ 0 - 2
packages/client/locale/src/locales/zh.ts

@@ -32,8 +32,6 @@ export const zh = {
   'unknown': '未知',
   'unknown': '未知',
   'none': '无',
   'none': '无',
   'truncated': '已截断',
   'truncated': '已截断',
-  'json.collapseNode': '收起 JSON 节点',
-  'json.expandNode': '展开 JSON 节点',
   'json.label': 'JSON',
   'json.label': 'JSON',
   'markdown.footnotes': '脚注',
   'markdown.footnotes': '脚注',
   'markdown.truncatedCharacters': '… 已截断,共 {total} 字符',
   'markdown.truncatedCharacters': '… 已截断,共 {total} 字符',

+ 2 - 2
packages/client/ui-chat/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 2333739f2827eb673525b9f00547d12e8d343888
-README.zh.md: 0de859abfcfac1679d605cf4447a2f4ac2312f5d
+README.md: 254c511a023aa8217b73cc0fe3802f13f2a63d80
+README.zh.md: d79f8b5060bd6a452ddaf018074adaea88b85604

+ 2 - 0
packages/client/ui-chat/README.md

@@ -41,6 +41,8 @@ Each nonempty appended `system/message` owns a collapsed prompt row, including a
 
 
 A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total.
 A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total.
 
 
+Reply timing survives reopening history: recorded token timestamps supply first-token latency and generation duration, including the Step's first token from an earlier retry attempt. Missing Step starts or token timestamps leave the affected metrics unavailable.
+
 <a id="completed-turn-footer"></a>
 <a id="completed-turn-footer"></a>
 ## Completed-turn footer
 ## Completed-turn footer
 
 

+ 2 - 0
packages/client/ui-chat/README.zh.md

@@ -43,6 +43,8 @@ kind: "package-reference"
 
 
 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。
 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。
 
 
+重新打开历史后仍保留回复计时:记录中的 token 时间戳提供首 token 延迟和生成耗时,包括步骤中更早重试尝试的首个 token。缺少步骤起点或 token 时间戳时,受影响的指标保持不可用。
+
 <a id="completed-turn-footer"></a>
 <a id="completed-turn-footer"></a>
 ## 已完成轮次的页脚
 ## 已完成轮次的页脚
 
 

+ 8 - 1
packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx

@@ -58,6 +58,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
     return { resolve: value => localPathMediaUrl(protocol, origin, value) }
     return { resolve: value => localPathMediaUrl(protocol, origin, value) }
   }, [])
   }, [])
   const last = blocks.length - 1
   const last = blocks.length - 1
+  const hasBodyOrToolCall = blocks.some(block => block.kind === 'tool-call'
+    || (block.kind === 'text' && block.text.trim() !== ''))
   // Tool-call heads render as tool rows in the chat view's grouping pass, so
   // Tool-call heads render as tool rows in the chat view's grouping pass, so
   // a node that is only those heads (or empty) would paint an empty root
   // a node that is only those heads (or empty) would paint an empty root
   // between tool groups — skip the shell unless something visible remains.
   // between tool groups — skip the shell unless something visible remains.
@@ -89,7 +91,12 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
             hidden={reasoningHidden}
             hidden={reasoningHidden}
             reveal={revealProcess}
             reveal={revealProcess}
           >
           >
-            <ReasoningRow text={block.text} running={streaming && i === last} t={t} />
+            <ReasoningRow
+              text={block.text}
+              running={streaming && i === last}
+              defaultExpanded={!hasBodyOrToolCall && block.text.trim() !== ''}
+              t={t}
+            />
           </ProcessReasoning>,
           </ProcessReasoning>,
         )
         )
         break
         break

+ 10 - 3
packages/client/ui-chat/src/client/chat/ReasoningRow.tsx

@@ -22,11 +22,18 @@ function latestLine(text: string): string {
  * the complete text.
  * the complete text.
  * @param props.text - complete or streaming reasoning text.
  * @param props.text - complete or streaming reasoning text.
  * @param props.running - whether this block is the streaming tail.
  * @param props.running - whether this block is the streaming tail.
+ * @param props.defaultExpanded - expanded state until the reader toggles it.
  * @param props.t - conversation locale seat for the running status.
  * @param props.t - conversation locale seat for the running status.
  * @returns the reasoning disclosure.
  * @returns the reasoning disclosure.
  */
  */
-export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) {
-  const [expanded, setExpanded] = useState(false)
+export function ReasoningRow({ text, running, defaultExpanded, t }: {
+  text: string
+  running: boolean
+  defaultExpanded: boolean
+  t: ChatViewSlotProps['t']
+}) {
+  const [expandedOverride, setExpandedOverride] = useState<boolean>()
+  const expanded = expandedOverride ?? defaultExpanded
   const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '')
   const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '')
 
 
   return (
   return (
@@ -47,7 +54,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool
         open={expanded}
         open={expanded}
         expandable
         expandable
         expandOnRowClick
         expandOnRowClick
-        onToggle={() => { setExpanded(value => !value) }}
+        onToggle={() => { setExpandedOverride(!expanded) }}
         collapsedContent={(
         collapsedContent={(
           <>
           <>
             <span className={css.separator} aria-hidden />
             <span className={css.separator} aria-hidden />

+ 21 - 2
packages/client/ui-chat/src/client/conversation-nodes/assistant.ts

@@ -4,6 +4,7 @@ import type {
   ConversationNodeContext, ConversationNodeDefinition,
   ConversationNodeContext, ConversationNodeDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import { assistantStreamFirstTokenTime } from '@deepseek-ai/dsh-llm/assistant-stream'
 import type {} from '@deepseek-ai/dsh-llm-retry/types'
 import type {} from '@deepseek-ai/dsh-llm-retry/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { AssistantChatData } from '../contract/chat-nodes.ts'
 import type { AssistantChatData } from '../contract/chat-nodes.ts'
@@ -161,6 +162,17 @@ function updateChunk(
   }
   }
 }
 }
 
 
+/** The Step retains its first token across live chunks and settled retry attempts. */
+function settleTiming(
+  state: AssistantState,
+  event: SessionEvent<'assistant/message' | 'assistant/attempt'>,
+): AssistantState {
+  return {
+    ...state,
+    firstTokenTime: state.firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream),
+  }
+}
+
 function settleMessage(
 function settleMessage(
   state: AssistantState,
   state: AssistantState,
   match: ConversationMatch,
   match: ConversationMatch,
@@ -168,7 +180,7 @@ function settleMessage(
 ): AssistantState {
 ): AssistantState {
   const blocks = toAssistantBlocks(event.data.message.content)
   const blocks = toAssistantBlocks(event.data.message.content)
   return {
   return {
-    ...state,
+    ...settleTiming(state, event),
     blocks,
     blocks,
     visibleBlocks: countVisibleBlocks(blocks),
     visibleBlocks: countVisibleBlocks(blocks),
     hidden: false,
     hidden: false,
@@ -236,6 +248,11 @@ function fallbackState(context: ConversationNodeContext<AssistantState>): Assist
       state = updateChunk(state, match.event.data.chunk, match.event.seq, match.event.time)
       state = updateChunk(state, match.event.data.chunk, match.event.seq, match.event.time)
       continue
       continue
     }
     }
+    if (match.event.type === 'assistant/attempt') {
+      state ??= initialState(match.event.data.turn, match.event.data.step)
+      state = settleTiming(state, match.event)
+      continue
+    }
     if (match.event.type === 'assistant/message') {
     if (match.event.type === 'assistant/message') {
       state ??= initialState(match.event.data.turn, match.event.data.step)
       state ??= initialState(match.event.data.turn, match.event.data.step)
       state = settleMessage(state, match, match.event)
       state = settleMessage(state, match, match.event)
@@ -296,6 +313,7 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
   match: (event) => {
   match: (event) => {
     if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
     if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
     if (event.type === 'assistant/live-chunk'
     if (event.type === 'assistant/live-chunk'
+      || event.type === 'assistant/attempt'
       || (event.type === 'assistant/message' && event.surfaceOp === 'append')) {
       || (event.type === 'assistant/message' && event.surfaceOp === 'append')) {
       return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
       return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
     }
     }
@@ -313,13 +331,14 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
       return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time)
       return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time)
     }
     }
     if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event)
     if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event)
+    if (match.event.type === 'assistant/attempt') return settleTiming(context.state, match.event)
     if (match.event.type === 'llm/retry') {
     if (match.event.type === 'llm/retry') {
       return resetForRetry(context.state)
       return resetForRetry(context.state)
     }
     }
     return context.state
     return context.state
   },
   },
   publication: (match) => {
   publication: (match) => {
-    if (match.event.type === 'step/start') return 'none'
+    if (match.event.type === 'step/start' || match.event.type === 'assistant/attempt') return 'none'
     if (match.event.type !== 'assistant/live-chunk') return 'immediate'
     if (match.event.type !== 'assistant/live-chunk') return 'immediate'
     const type = match.event.data.chunk.type
     const type = match.event.data.chunk.type
     return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
     return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'

+ 9 - 2
packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts

@@ -1056,9 +1056,16 @@ describe('built-in conversation node Definitions', () => {
     const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
     const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
     expect(finalNode).toMatchObject({
     expect(finalNode).toMatchObject({
       blocks: [{ kind: 'text', text: 'done' }],
       blocks: [{ kind: 'text', text: 'done' }],
-      timing: { firstTokenTime: null },
+      timing: { firstTokenTime: 1_999 },
     })
     })
 
 
+    const windowed = assembler(finalizedInputs.slice(2), true)
+    const timing = () => (node(snapshot(windowed), 'assistant-step')?.data as AssistantChatData).finalNode?.timing
+    expect(timing()).toMatchObject({ stepStartTime: null, firstTokenTime: 1_999 })
+    windowed.prepend(finalizedInputs.slice(0, 2), false)
+    windowed.flush()
+    expect(timing()).toEqual(finalNode?.timing)
+
     const namedToolHistory = [
     const namedToolHistory = [
       at(40, 'turn/start', { turn: 3 }),
       at(40, 'turn/start', { turn: 3 }),
       at(41, 'step/start', { turn: 3, step: 1 }),
       at(41, 'step/start', { turn: 3, step: 1 }),
@@ -1083,7 +1090,7 @@ describe('built-in conversation node Definitions', () => {
     const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
     const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
     expect(namedTool).toMatchObject({
     expect(namedTool).toMatchObject({
       blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }],
       blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }],
-      timing: { firstTokenTime: null },
+      timing: { firstTokenTime: 4_000 },
     })
     })
   })
   })
 
 

+ 29 - 2
packages/client/ui-chat/tests/reasoning-row.client.spec.tsx

@@ -14,6 +14,26 @@ const t = makeTranslate(zh, commonZh)
 const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
 const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
 
 
 describe('ReasoningRow', () => {
 describe('ReasoningRow', () => {
+  it('collapses when body text arrives and preserves a later manual expansion', () => {
+    const reasoning = { kind: 'reasoning' as const, text: 'Inspect the session\nCheck persistence' }
+    const view = render(
+      <AssistantMarkdown t={t} blocks={[reasoning]} streaming renderMessageImages={renderMessageImages} />,
+    )
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
+    view.rerender(
+      <AssistantMarkdown t={t} blocks={[reasoning, { kind: 'text', text: 'Answer' }]}
+        streaming renderMessageImages={renderMessageImages} />,
+    )
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(view.getByText('思考'))
+    view.rerender(
+      <AssistantMarkdown t={t} blocks={[reasoning, { kind: 'text', text: 'Complete answer' }]}
+        streaming={false} renderMessageImages={renderMessageImages} />,
+    )
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
+    expect(view.getByText(/Check persistence/)).toBeTruthy()
+  })
+
   it('follows the latest streaming line, then restores the settled first line', () => {
   it('follows the latest streaming line, then restores the settled first line', () => {
     const view = render(
     const view = render(
       <AssistantMarkdown
       <AssistantMarkdown
@@ -24,6 +44,8 @@ describe('ReasoningRow', () => {
       />,
       />,
     )
     )
     expect(view.getByText('运行中')).toBeTruthy()
     expect(view.getByText('运行中')).toBeTruthy()
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
+    fireEvent.click(view.getByText('思考'))
     expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-follow-end'))
     expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-follow-end'))
       .toBe('true')
       .toBe('true')
 
 
@@ -62,6 +84,7 @@ describe('ReasoningRow', () => {
     )
     )
     const row = view.getByRole('button')
     const row = view.getByRole('button')
 
 
+    fireEvent.click(view.getByText('思考'))
     fireEvent.click(view.getByText('Inspect the session'))
     fireEvent.click(view.getByText('Inspect the session'))
     expect(row.getAttribute('aria-expanded')).toBe('true')
     expect(row.getAttribute('aria-expanded')).toBe('true')
     expect(view.getByText(/Check persistence/)).toBeTruthy()
     expect(view.getByText(/Check persistence/)).toBeTruthy()
@@ -91,6 +114,7 @@ describe('ReasoningRow', () => {
       />,
       />,
     )
     )
 
 
+    fireEvent.click(view.getByText('思考'))
     expect(view.getByText('Comparing checkout and merge bases')).toBeTruthy()
     expect(view.getByText('Comparing checkout and merge bases')).toBeTruthy()
     expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull()
     expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull()
 
 
@@ -98,7 +122,7 @@ describe('ReasoningRow', () => {
     expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).toBe(text)
     expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).toBe(text)
   })
   })
 
 
-  it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
+  it('opens reasoning-only replies as plain prose and allows manual collapse', () => {
     const view = render(
     const view = render(
       <AssistantMarkdown
       <AssistantMarkdown
         t={t}
         t={t}
@@ -107,10 +131,13 @@ describe('ReasoningRow', () => {
         renderMessageImages={renderMessageImages}
         renderMessageImages={renderMessageImages}
       />,
       />,
     )
     )
-    fireEvent.click(view.getByText('思考'))
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
     expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
     expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
     expect(view.queryByText('IN')).toBeNull()
     expect(view.queryByText('IN')).toBeNull()
     expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
     expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
     expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
     expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
+    fireEvent.click(view.getByText('思考'))
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
+    expect(view.queryByText(/Check persistence/)).toBeNull()
   })
   })
 })
 })

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: d22fa2eed35457c0599fd41473102b1aebe4bd2e
-README.zh.md: b53cf76dc8b8a253da83d1e94b6f9f1b5abf19cc
+README.md: 07e61be1bb993716c85273aac8ee6e650d221365
+README.zh.md: 194d8e994d82ae167ce2c7af06d9b199ef066b27

+ 3 - 1
packages/client/ui-primitives/README.md

@@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot
 | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
 | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
 | `Toast` | Transient top-center banner held for the owner's `holdMs`. |
 | `Toast` | Transient top-center banner held for the owner's `holdMs`. |
 | `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
 | `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
-| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. |
+| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. |
 | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
 | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
 | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon` | Glyphs and brand marks. Use `LinkIcon` for 14px clickable-link categories. |
 | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon` | Glyphs and brand marks. Use `LinkIcon` for 14px clickable-link categories. |
 | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. |
 | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. |
@@ -74,6 +74,8 @@ The catalog above lists what each export is for; this section covers the behavio
 `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
 `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
 
 
 
 
+`JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree.
+
 ### Localizing copy
 ### Localizing copy
 
 
 The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.
 The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.

+ 3 - 1
packages/client/ui-primitives/README.zh.md

@@ -52,7 +52,7 @@ kind: "package-library"
 | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
 | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
 | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
 | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
 | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
 | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
-| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。 |
+| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 |
 | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
 | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
 | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon` | 字形与品牌标识。`LinkIcon` 用于 14px 的可点击链接分类。 |
 | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon` | 字形与品牌标识。`LinkIcon` 用于 14px 的可点击链接分类。 |
 | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 |
 | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 |
@@ -74,6 +74,8 @@ kind: "package-library"
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 
 
 
 
+`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
+
 ### 本地化文案
 ### 本地化文案
 
 
 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。
 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。

+ 141 - 18
packages/client/ui-primitives/src/JsonTree.module.css

@@ -29,17 +29,17 @@
 
 
 .container {
 .container {
   box-sizing: border-box;
   box-sizing: border-box;
-  width: max-content;
-  min-width: 100%;
+  width: 100%;
+  min-width: 0;
   margin: 0;
   margin: 0;
-  padding: 6px 8px 8px;
+  padding: 6px 8px 8px 8px;
   white-space: pre;
   white-space: pre;
 }
 }
 
 
 .expandedTopLevel {
 .expandedTopLevel {
   box-sizing: border-box;
   box-sizing: border-box;
-  width: max-content;
-  min-width: 100%;
+  width: 100%;
+  min-width: 0;
   padding: 6px 8px 8px 14px;
   padding: 6px 8px 8px 14px;
 }
 }
 
 
@@ -66,11 +66,13 @@
 .row {
 .row {
   position: relative;
   position: relative;
   box-sizing: border-box;
   box-sizing: border-box;
-  min-width: 100%;
+  min-width: 0;
   min-height: 16px;
   min-height: 16px;
   margin: 0;
   margin: 0;
   padding: 0 0 0 10px;
   padding: 0 0 0 10px;
   list-style: none;
   list-style: none;
+  overflow-wrap: anywhere;
+  white-space: pre-wrap;
 }
 }
 
 
 .row:not(.topLevelBracket):hover:not(:has(.row:hover))::after,
 .row:not(.topLevelBracket):hover:not(:has(.row:hover))::after,
@@ -81,13 +83,13 @@
   top: 0;
   top: 0;
   right: 0;
   right: 0;
   left: 0;
   left: 0;
-  height: 16px;
+  height: var(--json-tree-row-height, 16px);
   background: var(--json-tree-hover);
   background: var(--json-tree-hover);
   content: '';
   content: '';
   pointer-events: none;
   pointer-events: none;
 }
 }
 
 
-.row > span:not(.expander) {
+.row > span:not(.expander, .copySlot) {
   position: relative;
   position: relative;
   z-index: 1;
   z-index: 1;
 }
 }
@@ -106,6 +108,100 @@
   color: var(--json-tree-string);
   color: var(--json-tree-string);
 }
 }
 
 
+.stringField {
+  position: relative;
+  z-index: 1;
+  display: flow-root;
+}
+
+.stringText {
+  display: block;
+  max-height: calc(var(--json-tree-collapsed-lines) * 16px);
+  padding-left: 12px;
+  overflow: hidden;
+  text-indent: -12px;
+  word-break: break-all;
+}
+
+.stringRaw {
+  margin: 4px 0 0 12px;
+  overflow: auto;
+  color: var(--dsw-alias-label-primary);
+  font: inherit;
+  overflow-wrap: normal;
+  white-space: pre;
+  word-break: normal;
+}
+
+.stringActions {
+  position: absolute;
+  top: 1px;
+  right: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 4px;
+  width: fit-content;
+  height: 16px;
+}
+
+.stringRaw[data-wrap='true'] {
+  overflow-wrap: anywhere;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+
+.stringActions > .copySlot {
+  opacity: 1;
+  pointer-events: auto;
+}
+
+.stringToggleSlot {
+  display: flex;
+  float: right;
+  align-items: flex-end;
+  height: calc(var(--json-tree-collapsed-lines) * 16px);
+  margin-left: 2px;
+  shape-outside: inset(calc((var(--json-tree-collapsed-lines) - 1) * 16px) 0 0);
+  pointer-events: none;
+}
+
+.stringToggle {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 2px;
+  margin: 0;
+  padding: 0;
+  border: 0;
+  color: var(--dsw-alias-label-secondary);
+  background: transparent;
+  font: inherit;
+  text-indent: 0;
+  white-space: nowrap;
+  cursor: pointer;
+  pointer-events: auto;
+}
+
+.stringToggle:hover {
+  color: var(--dsw-alias-label-primary);
+}
+
+.stringToggle:focus-visible {
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: 1px;
+}
+
+.row:has(> .stringField) {
+  --json-tree-row-height: 100%;
+}
+
+.summary {
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .numberValue {
 .numberValue {
   color: var(--json-tree-number);
   color: var(--json-tree-number);
 }
 }
@@ -134,13 +230,38 @@
   color: var(--dsw-alias-label-tertiary);
   color: var(--dsw-alias-label-tertiary);
 }
 }
 
 
-.copyAnchor {
-  position: fixed;
-  z-index: 3;
+.copySlot {
   display: inline-flex;
   display: inline-flex;
+  width: 20px;
+  height: 16px;
+  opacity: 0;
+  pointer-events: none;
 }
 }
 
 
-.copyButton {
+.row > .copySlot {
+  position: absolute;
+  z-index: 2;
+  top: 0;
+  right: 0;
+}
+
+.row:has(> .stringField) > .copySlot {
+  top: auto;
+  bottom: 0;
+}
+
+.row:has(> .copySlot) > .stringField .stringToggleSlot {
+  padding-right: 24px;
+}
+
+.row:hover:not(:has(.row:hover)) > .copySlot,
+.row[data-json-copy-active] > .copySlot,
+.copySlot:focus-within {
+  opacity: 1;
+  pointer-events: auto;
+}
+
+.actionButton {
   box-sizing: border-box;
   box-sizing: border-box;
   display: inline-flex;
   display: inline-flex;
   align-items: center;
   align-items: center;
@@ -152,22 +273,24 @@
   border: 0;
   border: 0;
   border-radius: 3px;
   border-radius: 3px;
   color: var(--dsw-alias-label-secondary);
   color: var(--dsw-alias-label-secondary);
-  background: var(--dsw-alias-bg-layer-1);
-  box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1);
+  background: transparent;
   cursor: pointer;
   cursor: pointer;
 }
 }
 
 
-.copyButton:hover {
+.actionButton:hover {
   color: var(--dsw-alias-label-primary);
   color: var(--dsw-alias-label-primary);
-  background: var(--dsw-alias-interactive-bg-hover);
 }
 }
 
 
-.copyButton:focus-visible {
+.actionButton[aria-pressed='true'] {
+  color: var(--dsw-alias-state-business-primary);
+}
+
+.actionButton:focus-visible {
   outline: 1px solid var(--dsw-alias-state-business-primary);
   outline: 1px solid var(--dsw-alias-state-business-primary);
   outline-offset: -1px;
   outline-offset: -1px;
 }
 }
 
 
-.copyButton[data-state='failed'] {
+.actionButton[data-state='failed'] {
   color: var(--dsw-alias-state-error-primary);
   color: var(--dsw-alias-state-error-primary);
 }
 }
 
 

+ 349 - 141
packages/client/ui-primitives/src/JsonTree.tsx

@@ -1,12 +1,12 @@
 import clsx from 'clsx'
 import clsx from 'clsx'
-import { useEffect, useId, useRef, useState } from 'react'
+import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
 import type {
 import type {
+  CSSProperties,
   KeyboardEvent as ReactKeyboardEvent,
   KeyboardEvent as ReactKeyboardEvent,
   MouseEvent as ReactMouseEvent,
   MouseEvent as ReactMouseEvent,
   ReactNode,
   ReactNode,
-  UIEvent as ReactUIEvent,
 } from 'react'
 } from 'react'
-import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx'
+import { IconCheckOutline16, IconCopyOutline16, IconWrapLinesOutline16 } from './icons/index.tsx'
 import { Menu } from './Menu.tsx'
 import { Menu } from './Menu.tsx'
 import type { MenuEntry } from './Menu.tsx'
 import type { MenuEntry } from './Menu.tsx'
 import css from './JsonTree.module.css'
 import css from './JsonTree.module.css'
@@ -65,10 +65,103 @@ interface RowTarget {
   value: unknown
   value: unknown
 }
 }
 
 
-interface CopyTarget extends RowTarget {
-  left: number
-  side: 'bottom' | 'top'
-  top: number
+type CopyMode = 'json' | 'path' | 'prettyJson' | 'value'
+
+interface CopySnapshot {
+  id: string
+  target: RowTarget
+  state: 'idle' | 'copied' | 'failed'
+  menuOpen: boolean
+}
+
+/** Notify only the old and new row actions; JSON values do not subscribe to hover state. */
+function createCopyStore() {
+  let current: CopySnapshot | undefined
+  const listeners = new Map<string, Set<() => void>>()
+  return {
+    get: () => current,
+    set(next: CopySnapshot | undefined) {
+      const previous = current?.id
+      current = next
+      for (const id of new Set([previous, next?.id])) {
+        if (id === undefined) continue
+        for (const listener of listeners.get(id) ?? []) listener()
+      }
+    },
+    subscribe(id: string, listener: () => void) {
+      let row = listeners.get(id)
+      if (row === undefined) listeners.set(id, row = new Set())
+      row.add(listener)
+      return () => {
+        row.delete(listener)
+        if (row.size === 0) listeners.delete(id)
+      }
+    },
+  }
+}
+
+function JsonCopyAction({ store, target, persistent, labels, onCopy, onClose }: {
+  store: ReturnType<typeof createCopyStore>
+  target: RowTarget
+  persistent: boolean
+  labels: JsonTreeLabels
+  onCopy: (target: RowTarget, mode: CopyMode) => Promise<void>
+  onClose: () => void
+}) {
+  const id = pathId(target.path)
+  const subscribe = useCallback((listener: () => void) => store.subscribe(id, listener), [id, store])
+  const getSnapshot = () => {
+    const current = store.get()
+    return current?.id === id ? current : undefined
+  }
+  const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
+  const buttonRef = useRef<HTMLButtonElement>(null)
+  const state = snapshot?.state ?? 'idle'
+  const object = typeof target.value === 'object' && target.value !== null
+  const copyTitle = state === 'copied'
+    ? labels.copied
+    : state === 'failed'
+      ? labels.copyFailed
+      : object ? labels.copyPrettyJson : labels.copyValue
+  return (
+    <span className={css.copySlot}>
+      {(persistent || snapshot !== undefined) && (
+        <Menu
+          open={snapshot?.menuOpen === true}
+          compact
+          portal
+          align="end"
+          anchor={(
+            <button
+              ref={buttonRef}
+              type="button"
+              className={css.actionButton}
+              data-json-copy-button
+              data-state={state}
+              aria-label={copyTitle}
+              title={labels.copyButtonTitle(copyTitle)}
+              onClick={() => void onCopy(target, object ? 'prettyJson' : 'value')}
+              onContextMenu={(event) => {
+                event.preventDefault()
+                event.stopPropagation()
+                store.set({ id, target, state, menuOpen: true })
+              }}
+            >
+              {state === 'copied'
+                ? <IconCheckOutline16 size={12} />
+                : <IconCopyOutline16 size={12} />}
+            </button>
+          )}
+          items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
+          onSelect={(mode) => {
+            void onCopy(target, mode as CopyMode)
+          }}
+          onClose={onClose}
+          getAnchorRect={() => (buttonRef.current as HTMLButtonElement).getBoundingClientRect()}
+        />
+      )}
+    </span>
+  )
 }
 }
 
 
 function isExpandableValue(value: unknown): value is object | unknown[] {
 function isExpandableValue(value: unknown): value is object | unknown[] {
@@ -224,6 +317,8 @@ function NodeField({
 }
 }
 
 
 interface JsonTreeNodeProps {
 interface JsonTreeNodeProps {
+  collapsedStringLines: number
+  stringWrapping: JsonTreeProps['stringWrapping']
   field?: string
   field?: string
   initialExpanded: boolean
   initialExpanded: boolean
   labels: JsonTreeLabels
   labels: JsonTreeLabels
@@ -231,11 +326,180 @@ interface JsonTreeNodeProps {
   onClaimTabStop: (id: string) => void
   onClaimTabStop: (id: string) => void
   onRowHover: (row: HTMLElement, target: RowTarget) => void
   onRowHover: (row: HTMLElement, target: RowTarget) => void
   path: JsonPath
   path: JsonPath
+  renderCopy: ((target: RowTarget, persistent?: boolean) => ReactNode) | undefined
   tabStopId: string | null
   tabStopId: string | null
   value: unknown
   value: unknown
 }
 }
 
 
+function JsonString({
+  collapsedStringLines,
+  stringWrapping,
+  field,
+  labels,
+  lastElement,
+  renderCopy,
+  value,
+}: {
+  collapsedStringLines: number
+  stringWrapping: JsonTreeProps['stringWrapping']
+  field: string | undefined
+  labels: JsonTreeLabels
+  lastElement: boolean
+  renderCopy: ((persistent?: boolean) => ReactNode) | undefined
+  value: string
+}) {
+  const contentsId = useId()
+  const contentRef = useRef<HTMLSpanElement>(null)
+  const rawRef = useRef<HTMLPreElement>(null)
+  const [expanded, setExpanded] = useState(false)
+  const [wrapped, setWrapped] = useState(false)
+  const [truncated, setTruncated] = useState(false)
+
+  useLayoutEffect(() => {
+    if (expanded) return
+    const content = contentRef.current as HTMLSpanElement
+    const measure = () => {
+      const lineHeight = Number.parseFloat(getComputedStyle(content).lineHeight)
+      setTruncated(content.scrollHeight > lineHeight * collapsedStringLines)
+    }
+    measure()
+    if (typeof ResizeObserver === 'undefined') return
+    const observer = new ResizeObserver(measure)
+    observer.observe(content)
+    return () => { observer.disconnect() }
+  }, [collapsedStringLines, expanded, field, lastElement, value])
+
+  useLayoutEffect(() => {
+    if (!expanded) return
+    const raw = rawRef.current as HTMLPreElement
+    // Keep raw text within the window and clipping ancestors outside the tree.
+    // Capture scrolling because an ancestor can move the string without resizing it.
+    const clips: HTMLElement[] = []
+    const tree = raw.closest<HTMLElement>(`.${css.root}`) as HTMLElement
+    for (let parent = tree.parentElement; parent !== null; parent = parent.parentElement) {
+      if (/auto|scroll|hidden|clip/.test(getComputedStyle(parent).overflowY)) clips.push(parent)
+    }
+    const measure = () => {
+      let top = 0
+      let bottom = window.innerHeight
+      for (const clip of clips) {
+        const rect = clip.getBoundingClientRect()
+        const style = getComputedStyle(clip)
+        top = Math.max(top, rect.top + clip.clientTop)
+        bottom = Math.min(bottom, rect.top + clip.clientTop + clip.clientHeight
+          - Number.parseFloat(style.paddingBottom))
+      }
+      const available = bottom - Math.max(top, raw.getBoundingClientRect().top)
+      raw.style.maxHeight = `${Math.max(16, available - 4)}px`
+    }
+    measure()
+    const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
+    observer?.observe(raw)
+    for (const clip of clips) observer?.observe(clip)
+    window.addEventListener('resize', measure)
+    window.addEventListener('scroll', measure, true)
+    return () => {
+      observer?.disconnect()
+      window.removeEventListener('resize', measure)
+      window.removeEventListener('scroll', measure, true)
+    }
+  }, [expanded, value])
+
+  if (expanded) {
+    const fieldId = `${contentsId}-field`
+    return (
+      <div className={css.stringField} data-expanded>
+        {field !== undefined && <span id={fieldId} className={css.label}>{fieldText(field)}:</span>}
+        <pre
+          ref={rawRef}
+          id={contentsId}
+          className={css.stringRaw}
+          data-wrap={wrapped}
+          tabIndex={0}
+          aria-labelledby={field === undefined ? undefined : fieldId}
+        >
+          {value}
+        </pre>
+        {!lastElement && <span className={css.punctuation}>,</span>}
+        <div className={css.stringActions}>
+          {stringWrapping !== undefined && (
+            <button
+              type="button"
+              className={css.actionButton}
+              aria-label={stringWrapping.label}
+              title={stringWrapping.label}
+              aria-pressed={wrapped}
+              aria-controls={contentsId}
+              onClick={() => {
+                const next = !wrapped
+                setWrapped(next)
+                stringWrapping.setDefault(next)
+              }}
+            >
+              <IconWrapLinesOutline16 size={12} />
+            </button>
+          )}
+          <button
+            type="button"
+            className={css.actionButton}
+            aria-label={labels.collapseNode}
+            title={labels.collapseNode}
+            aria-expanded
+            aria-controls={contentsId}
+            onClick={() => { setExpanded(false) }}
+          >
+            <svg
+              width="12"
+              height="12"
+              viewBox="0 0 16 16"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              aria-hidden="true"
+            >
+              <path d="M9.5 1.5v5h5M1.5 9.5h5v5" />
+            </svg>
+          </button>
+          {renderCopy?.(true)}
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <>
+      {renderCopy?.()}
+      <span className={css.stringField} data-expanded={expanded}>
+        <span ref={contentRef} id={contentsId} className={css.stringText}>
+          {truncated && (
+            <span className={css.stringToggleSlot}>
+              <button
+                type="button"
+                className={css.stringToggle}
+                aria-label={labels.expandNode}
+                aria-expanded={false}
+                aria-controls={contentsId}
+                onClick={() => {
+                  setWrapped(stringWrapping?.getDefault() ?? false)
+                  setExpanded(true)
+                }}
+              >
+                <span aria-hidden="true">…</span>{labels.expandNode}
+              </button>
+            </span>
+          )}
+          {field !== undefined && <span className={css.label}>{fieldText(field)}:</span>}
+          {primitiveValue(value)}
+          {!lastElement && <span className={css.punctuation}>,</span>}
+        </span>
+      </span>
+    </>
+  )
+}
+
 function JsonTreeNode({
 function JsonTreeNode({
+  collapsedStringLines,
+  stringWrapping,
   field,
   field,
   initialExpanded,
   initialExpanded,
   labels,
   labels,
@@ -243,6 +507,7 @@ function JsonTreeNode({
   onClaimTabStop,
   onClaimTabStop,
   onRowHover,
   onRowHover,
   path,
   path,
+  renderCopy,
   tabStopId,
   tabStopId,
   value,
   value,
 }: JsonTreeNodeProps) {
 }: JsonTreeNodeProps) {
@@ -281,10 +546,25 @@ function JsonTreeNode({
         onRowHover(event.currentTarget, { path, value })
         onRowHover(event.currentTarget, { path, value })
       }}
       }}
     >
     >
+      {typeof value !== 'string' && renderCopy?.({ path, value })}
       {children}
       {children}
     </div>
     </div>
   )
   )
 
 
+  if (typeof value === 'string') {
+    return row(
+      <JsonString
+        collapsedStringLines={collapsedStringLines}
+        stringWrapping={stringWrapping}
+        field={field}
+        value={value}
+        labels={labels}
+        lastElement={lastElement}
+        renderCopy={renderCopy === undefined ? undefined : persistent => renderCopy({ path, value }, persistent)}
+      />,
+    )
+  }
+
   if (!container) {
   if (!container) {
     return row((
     return row((
       <>
       <>
@@ -322,14 +602,18 @@ function JsonTreeNode({
         onClick={toggle}
         onClick={toggle}
         onKeyDown={onExpanderKeyDown}
         onKeyDown={onExpanderKeyDown}
       />
       />
-      <NodeField field={field} expandable onToggle={toggle} />
-      <span className={css.preview}>{previewValue(value, 0)}</span>
-      {!lastElement && <span className={css.punctuation}>,</span>}
+      <span className={css.summary}>
+        <NodeField field={field} expandable onToggle={toggle} />
+        <span className={css.preview}>{previewValue(value, 0)}</span>
+        {!lastElement && <span className={css.punctuation}>,</span>}
+      </span>
       {expanded && (
       {expanded && (
         <ul id={contentsId} role="group" className={css.children}>
         <ul id={contentsId} role="group" className={css.children}>
           {entries.map(([key, item], index) => (
           {entries.map(([key, item], index) => (
             <JsonTreeNode
             <JsonTreeNode
               key={key}
               key={key}
+              collapsedStringLines={collapsedStringLines}
+              stringWrapping={stringWrapping}
               field={key}
               field={key}
               value={item}
               value={item}
               path={[...path, Array.isArray(value) ? index : key]}
               path={[...path, Array.isArray(value) ? index : key]}
@@ -339,6 +623,7 @@ function JsonTreeNode({
               tabStopId={tabStopId}
               tabStopId={tabStopId}
               onClaimTabStop={onClaimTabStop}
               onClaimTabStop={onClaimTabStop}
               onRowHover={onRowHover}
               onRowHover={onRowHover}
+              renderCopy={renderCopy}
             />
             />
           ))}
           ))}
         </ul>
         </ul>
@@ -356,7 +641,7 @@ function formattedPath(path: JsonPath): string {
   }, '$')
   }, '$')
 }
 }
 
 
-function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
+function copyText(target: RowTarget, mode: CopyMode): string {
   if (mode === 'path') return formattedPath(target.path)
   if (mode === 'path') return formattedPath(target.path)
   if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
   if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
   if (mode === 'json') return JSON.stringify(target.value)
   if (mode === 'json') return JSON.stringify(target.value)
@@ -376,6 +661,17 @@ export interface JsonTreeProps {
   label: string
   label: string
   /** Optional positioning class owned by the caller. */
   /** Optional positioning class owned by the caller. */
   className?: string | undefined
   className?: string | undefined
+  /** Maximum visible lines per collapsed string; defaults to 3. */
+  collapsedStringLines?: number
+  /** Optional wrap toggle; each expansion reads the shared default without changing other open strings. */
+  stringWrapping?: {
+    /** Localized label for the wrapping toggle. */
+    label: string
+    /** Read the wrapping preference when a string is expanded. @returns Whether to wrap long lines. */
+    getDefault: () => boolean
+    /** Remember a user toggle for future expansions. @param wrapped - Whether to wrap long lines. */
+    setDefault: (wrapped: boolean) => void
+  } | undefined
   /** Whether JSON rows expose copy actions. */
   /** Whether JSON rows expose copy actions. */
   copyable?: boolean
   copyable?: boolean
   /** Whether the top-level object or array is always expanded. */
   /** Whether the top-level object or array is always expanded. */
@@ -393,6 +689,8 @@ export function JsonTree({
   data,
   data,
   label,
   label,
   className,
   className,
+  collapsedStringLines = 3,
+  stringWrapping,
   copyable = true,
   copyable = true,
   expandTopLevel = true,
   expandTopLevel = true,
   labels,
   labels,
@@ -407,14 +705,10 @@ export function JsonTree({
       ? null
       ? null
       : pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
       : pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
     : isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
     : isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
-  const rootRef = useRef<HTMLDivElement>(null)
   const activeRowRef = useRef<HTMLElement>()
   const activeRowRef = useRef<HTMLElement>()
-  const copyButtonRef = useRef<HTMLButtonElement>(null)
-  const copyMenuOpenRef = useRef(false)
   const resetTimer = useRef<ReturnType<typeof setTimeout>>()
   const resetTimer = useRef<ReturnType<typeof setTimeout>>()
-  const [copyTarget, setCopyTarget] = useState<CopyTarget>()
-  const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
-  const [copyMenuOpen, setCopyMenuOpen] = useState(false)
+  const copySequence = useRef(0)
+  const [copyStore] = useState(createCopyStore)
   const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
   const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
 
 
   const setActiveRow = (row: HTMLElement | undefined) => {
   const setActiveRow = (row: HTMLElement | undefined) => {
@@ -424,121 +718,74 @@ export function JsonTree({
   }
   }
 
 
   const clearCopyTarget = () => {
   const clearCopyTarget = () => {
+    copySequence.current += 1
+    if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
     setActiveRow(undefined)
     setActiveRow(undefined)
-    setCopyTarget(undefined)
-    setCopyState('idle')
-    copyMenuOpenRef.current = false
-    setCopyMenuOpen(false)
-  }
-
-  const copyPosition = (row: HTMLElement): Pick<CopyTarget, 'left' | 'side' | 'top'> => {
-    const root = rootRef.current
-    /* v8 ignore next -- row events and viewport listeners run only after the root ref mounts. */
-    if (root === null) throw new Error('JsonTree root is not mounted')
-    const rootRect = root.getBoundingClientRect()
-    const rowRect = row.getBoundingClientRect()
-    return {
-      left: rootRect.left + root.clientWidth - 26,
-      side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
-      top: rowRect.top,
-    }
-  }
-
-  const positionCopyButton = (row: HTMLElement, target: RowTarget) => {
-    const position = copyPosition(row)
-    setCopyTarget({ ...target, ...position })
-  }
-
-  const repositionCopyButton = (row: HTMLElement) => {
-    const position = copyPosition(row)
-    setCopyTarget((current) => {
-      /* v8 ignore next -- an active row and its copy target are installed together. */
-      if (current === undefined) return current
-      return { ...current, ...position }
-    })
+    copyStore.set(undefined)
   }
   }
 
 
   useEffect(() => () => {
   useEffect(() => () => {
+    copySequence.current += 1
     if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
     if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
     activeRowRef.current?.removeAttribute('data-json-copy-active')
     activeRowRef.current?.removeAttribute('data-json-copy-active')
   }, [])
   }, [])
 
 
   useEffect(() => {
   useEffect(() => {
-    activeRowRef.current?.removeAttribute('data-json-copy-active')
-    activeRowRef.current = undefined
-    copyMenuOpenRef.current = false
-    setCopyTarget(undefined)
-    setCopyState('idle')
-    setCopyMenuOpen(false)
+    clearCopyTarget()
     setTabStopId(initialTabStopId)
     setTabStopId(initialTabStopId)
   }, [data, expandTopLevel, initialTabStopId])
   }, [data, expandTopLevel, initialTabStopId])
 
 
-  useEffect(() => {
-    const reposition = () => {
-      const row = activeRowRef.current
-      if (row !== undefined) repositionCopyButton(row)
-    }
-    window.addEventListener('scroll', reposition, true)
-    window.addEventListener('resize', reposition)
-    return () => {
-      window.removeEventListener('scroll', reposition, true)
-      window.removeEventListener('resize', reposition)
-    }
-  }, [])
-
   const handleRowHover = (row: HTMLElement, target: RowTarget) => {
   const handleRowHover = (row: HTMLElement, target: RowTarget) => {
-    if (!copyable || copyMenuOpenRef.current) return
+    if (!copyable || copyStore.get()?.menuOpen) return
     if (activeRowRef.current === row) return
     if (activeRowRef.current === row) return
     setActiveRow(row)
     setActiveRow(row)
-    setCopyState('idle')
-    copyMenuOpenRef.current = false
-    setCopyMenuOpen(false)
-    positionCopyButton(row, target)
+    copyStore.set({ id: pathId(target.path), target, state: 'idle', menuOpen: false })
   }
   }
 
 
   const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
   const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
-    if (!copyable || copyMenuOpenRef.current) return
+    if (!copyable || copyStore.get()?.menuOpen) return
     /* v8 ignore next -- browser mouse events delivered through React target an Element. */
     /* v8 ignore next -- browser mouse events delivered through React target an Element. */
     if (!(event.target instanceof Element)) return
     if (!(event.target instanceof Element)) return
     if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget()
     if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget()
   }
   }
 
 
-  const handleScroll = (_event: ReactUIEvent<HTMLDivElement>) => {
-    const row = activeRowRef.current
-    if (row !== undefined) repositionCopyButton(row)
-  }
-
-  const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => {
-    /* v8 ignore next -- copy controls only render while their target exists. */
-    if (copyTarget === undefined) return
+  const copy = async (target: RowTarget, mode: CopyMode) => {
+    const sequence = ++copySequence.current
+    const snapshot: CopySnapshot = {
+      id: pathId(target.path), target, state: 'idle', menuOpen: false,
+    }
+    copyStore.set(snapshot)
+    let state: CopySnapshot['state']
     try {
     try {
-      await navigator.clipboard.writeText(copyText(copyTarget, mode))
-      setCopyState('copied')
+      await navigator.clipboard.writeText(copyText(target, mode))
+      state = 'copied'
     } catch {
     } catch {
-      setCopyState('failed')
+      state = 'failed'
     }
     }
+    const current = copyStore.get()
+    if (sequence !== copySequence.current || current?.target !== target) return
+    copyStore.set({ ...current, state })
     if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
     if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
-    resetTimer.current = setTimeout(() => { setCopyState('idle') }, 1_500)
+    resetTimer.current = setTimeout(() => {
+      const current = copyStore.get()
+      if (current?.target === target) copyStore.set({ ...current, state: 'idle' })
+    }, 1_500)
   }
   }
 
 
   const [rootOpen, rootClose] = bracketOf(data)
   const [rootOpen, rootClose] = bracketOf(data)
-  const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
-  const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
-  const copyTitle = copyState === 'copied'
-    ? labels.copied
-    : copyState === 'failed'
-      ? labels.copyFailed
-      : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue
+  const renderCopy = copyable ? (target: RowTarget, persistent = false) => (
+    <JsonCopyAction store={copyStore} target={target} persistent={persistent} labels={labels}
+      onCopy={copy} onClose={clearCopyTarget} />
+  ) : undefined
 
 
   return (
   return (
     <div
     <div
-      ref={rootRef}
       className={clsx(css.root, className)}
       className={clsx(css.root, className)}
+      style={{ '--json-tree-collapsed-lines': collapsedStringLines } as CSSProperties}
       onMouseOver={handleRootMouseOver}
       onMouseOver={handleRootMouseOver}
       onMouseLeave={() => {
       onMouseLeave={() => {
-        if (!copyMenuOpenRef.current) clearCopyTarget()
+        if (!copyStore.get()?.menuOpen) clearCopyTarget()
       }}
       }}
-      onScroll={handleScroll}
     >
     >
       {expandTopLevel
       {expandTopLevel
         ? (
         ? (
@@ -551,6 +798,7 @@ export function JsonTree({
                 handleRowHover(event.currentTarget, { path: [], value: data })
                 handleRowHover(event.currentTarget, { path: [], value: data })
               }}
               }}
             >
             >
+              {renderCopy?.({ path: [], value: data })}
               <span className={css.punctuation}>{rootOpen}</span>
               <span className={css.punctuation}>{rootOpen}</span>
             </div>
             </div>
             <div
             <div
@@ -561,6 +809,8 @@ export function JsonTree({
               {rootEntries.map(([key, value], index) => (
               {rootEntries.map(([key, value], index) => (
                 <JsonTreeNode
                 <JsonTreeNode
                   key={key}
                   key={key}
+                  collapsedStringLines={collapsedStringLines}
+                  stringWrapping={stringWrapping}
                   field={key}
                   field={key}
                   value={value}
                   value={value}
                   path={[Array.isArray(data) ? index : key]}
                   path={[Array.isArray(data) ? index : key]}
@@ -570,6 +820,7 @@ export function JsonTree({
                   tabStopId={tabStopId}
                   tabStopId={tabStopId}
                   onClaimTabStop={setTabStopId}
                   onClaimTabStop={setTabStopId}
                   onRowHover={handleRowHover}
                   onRowHover={handleRowHover}
+                  renderCopy={renderCopy}
                 />
                 />
               ))}
               ))}
             </div>
             </div>
@@ -581,6 +832,8 @@ export function JsonTree({
         : (
         : (
           <div aria-label={label} className={css.container} role="tree">
           <div aria-label={label} className={css.container} role="tree">
             <JsonTreeNode
             <JsonTreeNode
+              collapsedStringLines={collapsedStringLines}
+              stringWrapping={stringWrapping}
               value={data}
               value={data}
               path={[]}
               path={[]}
               labels={labels}
               labels={labels}
@@ -589,55 +842,10 @@ export function JsonTree({
               tabStopId={tabStopId}
               tabStopId={tabStopId}
               onClaimTabStop={setTabStopId}
               onClaimTabStop={setTabStopId}
               onRowHover={handleRowHover}
               onRowHover={handleRowHover}
+              renderCopy={renderCopy}
             />
             />
           </div>
           </div>
         )}
         )}
-      {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={labels.copyButtonTitle(copyTitle)}
-                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 ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
-            onSelect={(id) => {
-              void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
-              copyMenuOpenRef.current = false
-              setCopyMenuOpen(false)
-            }}
-            onClose={clearCopyTarget}
-            getAnchorRect={() => (
-              copyButtonRef.current as HTMLButtonElement
-            ).getBoundingClientRect()}
-          />
-        </span>
-      )}
     </div>
     </div>
   )
   )
 }
 }

+ 8 - 0
packages/client/ui-primitives/src/icons/index.tsx

@@ -938,6 +938,14 @@ export const IconArchiveOutline20 = ({ size = 20, className }: IconProps) => (
   </svg>
   </svg>
 )
 )
 
 
+/** Line-wrapping toggle glyph. */
+export const IconWrapLinesOutline16 = ({ size = 16, className }: IconProps) => (
+  <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none"
+    stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" aria-hidden="true">
+    <path d="M1.5 3.5h13M1.5 7.5h10.25a2.5 2.5 0 0 1 0 5H8m2-2-2 2 2 2M1.5 11.5h3" />
+  </svg>
+)
+
 /**
 /**
  * Plan row glyph of the composer menu (#3567): a document with three ruled
  * Plan row glyph of the composer menu (#3567): a document with three ruled
  * lines and a pencil over its corner. Drawn on a 14 grid whose content spans
  * lines and a pencil over its corner. Drawn on a 14 grid whose content spans

+ 7 - 3
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -29,6 +29,8 @@ export interface CodeBlockProps {
   contentRef?: Ref<HTMLDivElement> | undefined
   contentRef?: Ref<HTMLDivElement> | undefined
   /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */
   /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */
   lineNumbers?: boolean | undefined
   lineNumbers?: boolean | undefined
+  /** Show the language and copy header; false when the caller supplies a toolbar. Defaults to true. */
+  showHeader?: boolean | undefined
   /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
   /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
   copyLabel: string
   copyLabel: string
   /** Copy-button label during the post-copy confirmation window. */
   /** Copy-button label during the post-copy confirmation window. */
@@ -61,7 +63,9 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
   )
   )
 }
 }
 
 
-export function CodeBlock({ code, lang, streaming, className, contentRef, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) {
+export function CodeBlock({
+  code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel,
+}: CodeBlockProps) {
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
   const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
   const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
   const rootRef = useRef<HTMLDivElement>(null)
   const rootRef = useRef<HTMLDivElement>(null)
@@ -180,7 +184,7 @@ export function CodeBlock({ code, lang, streaming, className, contentRef, lineNu
         '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
         '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
       } as CSSProperties}>
       } as CSSProperties}>
       {/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */}
       {/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */}
-      <div className={css.bannerWrap}>
+      {showHeader && <div className={css.bannerWrap}>
         <div className={css.banner} data-code-block-banner>
         <div className={css.banner} data-code-block-banner>
           <div className={css.infostring}>{lang ?? ''}</div>
           <div className={css.infostring}>{lang ?? ''}</div>
           <div className={css.action}>
           <div className={css.action}>
@@ -189,7 +193,7 @@ export function CodeBlock({ code, lang, streaming, className, contentRef, lineNu
             </button>
             </button>
           </div>
           </div>
         </div>
         </div>
-      </div>
+      </div>}
       <div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
       <div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
     </div>
     </div>
   )
   )

+ 2 - 2
packages/client/ui-primitives/tests/icons.client.spec.tsx

@@ -17,8 +17,8 @@ const icons = Object.fromEntries(
 const iconNames = Object.keys(icons)
 const iconNames = Object.keys(icons)
 
 
 describe('ic_ds_ icon set', () => {
 describe('ic_ds_ icon set', () => {
-  it('exports the full icon set (46 deepsuite + 21 figma extracts + eleven product glyphs outside those sets)', () => {
-    expect(iconNames.length).toBe(78)
+  it('exports the full icon set (46 deepsuite + 21 figma extracts + twelve product glyphs outside those sets)', () => {
+    expect(iconNames.length).toBe(79)
     // The composer menu's own glyphs, pinned by name.
     // The composer menu's own glyphs, pinned by name.
     expect(iconNames).toEqual(expect.arrayContaining(['IconPlanOutline14', 'IconCompactOutline16', 'IconShieldOutline16']))
     expect(iconNames).toEqual(expect.arrayContaining(['IconPlanOutline14', 'IconCompactOutline16', 'IconShieldOutline16']))
   })
   })

+ 159 - 31
packages/client/ui-primitives/tests/json-tree.client.spec.tsx

@@ -12,10 +12,12 @@ function JsonTree(props: Omit<ComponentProps<typeof LocalizedJsonTree>, 'label'
   return <LocalizedJsonTree label="JSON" {...props} labels={jsonTreeLabels} />
   return <LocalizedJsonTree label="JSON" {...props} labels={jsonTreeLabels} />
 }
 }
 
 
-let writeText: ReturnType<typeof vi.fn>
+let writeText: ReturnType<typeof vi.fn<Clipboard['writeText']>>
+let originalClipboard: PropertyDescriptor | undefined
 
 
 beforeEach(() => {
 beforeEach(() => {
-  writeText = vi.fn().mockResolvedValue(undefined)
+  originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
+  writeText = vi.fn<Clipboard['writeText']>().mockResolvedValue(undefined)
   Object.defineProperty(navigator, 'clipboard', {
   Object.defineProperty(navigator, 'clipboard', {
     configurable: true,
     configurable: true,
     value: { writeText },
     value: { writeText },
@@ -25,9 +27,162 @@ beforeEach(() => {
 afterEach(() => {
 afterEach(() => {
   cleanup()
   cleanup()
   vi.useRealTimers()
   vi.useRealTimers()
+  vi.restoreAllMocks()
+  vi.unstubAllGlobals()
+  if (originalClipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard')
+  else Object.defineProperty(navigator, 'clipboard', originalClipboard)
 })
 })
 
 
+function stubStringLayout(scrollHeight = 200): void {
+  vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(scrollHeight)
+  const computedStyle = window.getComputedStyle.bind(window)
+  vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
+    const style = computedStyle(element)
+    style.lineHeight = '16px'
+    if (style.paddingBottom === '') style.paddingBottom = '0px'
+    return style
+  })
+}
+
 describe('JsonTree', () => {
 describe('JsonTree', () => {
+  it('ignores clipboard settlement after the row changes or the tree unmounts', async () => {
+    vi.useFakeTimers()
+    const pending: (() => void)[] = []
+    writeText.mockImplementation(() => new Promise<void>((resolve) => { pending.push(resolve) }))
+    const view = render(<JsonTree data={{ first: 1, second: 2 }} />)
+    const rows = screen.getAllByRole('treeitem')
+    fireEvent.mouseOver(rows[0] as HTMLElement)
+    fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
+    fireEvent.mouseOver(rows[1] as HTMLElement)
+    await act(async () => { pending[0]!() })
+    expect(screen.queryByRole('button', { name: 'Copied' })).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
+    view.unmount()
+    const timerCount = vi.getTimerCount()
+    await act(async () => { pending[1]!() })
+    expect(vi.getTimerCount()).toBe(timerCount)
+  })
+
+  it('updates copy actions without rereading JSON properties on hover', () => {
+    const readValue = vi.fn(() => 'payload '.repeat(100))
+    const data = Object.fromEntries(Array.from({ length: 500 }, (_, index) => [
+      `field${index}`,
+      { get value() { return readValue() } },
+    ]))
+    render(<JsonTree data={data} />)
+    const rows = within(screen.getByRole('tree')).getAllByRole('treeitem')
+    readValue.mockClear()
+
+    fireEvent.mouseOver(rows[0] as HTMLElement)
+    expect(within(rows[0] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy()
+    fireEvent.mouseOver(rows[1] as HTMLElement)
+    expect(within(rows[0] as HTMLElement).queryByRole('button', { name: 'Copy pretty JSON' })).toBeNull()
+    expect(within(rows[1] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy()
+    expect(readValue).not.toHaveBeenCalled()
+  })
+
+  it('expands raw strings without ResizeObserver and keeps the visible viewport limit', () => {
+    stubStringLayout()
+    vi.stubGlobal('ResizeObserver', undefined)
+    let rawTop = 150
+    const view = render(
+      <div style={{ overflowY: 'auto', paddingBottom: '10px' }}>
+        <JsonTree data={{ first: 'raw\ntext', last: 'last\ntext' }} />
+      </div>,
+    )
+    const clip = view.container.firstElementChild as HTMLElement
+    vi.spyOn(clip, 'clientTop', 'get').mockReturnValue(2)
+    vi.spyOn(clip, 'clientHeight', 'get').mockReturnValue(140)
+    vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      return new DOMRect(0, this === clip ? 100 : rawTop, 200, 140)
+    })
+    const rows = within(screen.getByRole('tree')).getAllByRole('treeitem')
+    fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Expand JSON node' }))
+    const raw = rows[0]?.querySelector('pre') as HTMLPreElement
+    expect(raw.textContent).toBe('raw\ntext')
+    expect(raw.style.maxHeight).toBe('78px')
+    expect(raw.nextElementSibling?.textContent).toBe(',')
+
+    rawTop = 80
+    fireEvent.scroll(clip)
+    expect(raw.style.maxHeight).toBe('126px')
+    rawTop = 300
+    fireEvent.resize(window)
+    expect(raw.style.maxHeight).toBe('16px')
+    fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Collapse JSON node' }))
+    expect(raw.isConnected).toBe(false)
+    rawTop = 100
+    fireEvent.scroll(clip)
+    expect(raw.style.maxHeight).toBe('16px')
+
+    fireEvent.click(within(rows[1] as HTMLElement).getByRole('button', { name: 'Expand JSON node' }))
+    expect(rows[1]?.querySelector('pre')?.nextElementSibling?.textContent).not.toBe(',')
+  })
+
+  it('shows the string expander only beyond the configured collapsed line count', () => {
+    stubStringLayout(48)
+    let resize: (() => void) | undefined
+    const disconnect = vi.fn()
+    vi.stubGlobal('ResizeObserver', class {
+      constructor(callback: () => void) { resize = callback }
+      observe() {}
+      disconnect = disconnect
+    })
+    const data = { text: 'three lines of text' }
+    const view = render(<JsonTree data={data} />)
+    expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull()
+    view.rerender(<JsonTree data={data} collapsedStringLines={2} />)
+    expect(screen.getByRole('button', { name: 'Expand JSON node' })).toBeTruthy()
+    vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(16)
+    act(() => { resize?.() })
+    expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull()
+    view.unmount()
+    expect(disconnect).toHaveBeenCalledTimes(2)
+  })
+
+  it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => {
+    stubStringLayout()
+    vi.stubGlobal('ResizeObserver', class {
+      observe() {}
+      disconnect() {}
+    })
+    let wrapped = false
+    const stringWrapping = {
+      label: 'Wrap lines',
+      getDefault: () => wrapped,
+      setDefault: (value: boolean) => { wrapped = value },
+    }
+    const original = `  leading spaces\n\t"quoted" \\${'long'.repeat(100)}\nlast line\n`
+    render(<JsonTree data={{ first: original, second: original }} stringWrapping={stringWrapping} />)
+    const [first, second] = within(screen.getByRole('tree')).getAllByRole('treeitem')
+    const a = within(first as HTMLElement)
+    const b = within(second as HTMLElement)
+    fireEvent.click(a.getByRole('button', { name: 'Expand JSON node' }))
+    fireEvent.click(b.getByRole('button', { name: 'Expand JSON node' }))
+    expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false')
+
+    fireEvent.click(a.getByRole('button', { name: 'Wrap lines' }))
+    expect(wrapped).toBe(true)
+    expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true')
+    expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false')
+
+    fireEvent.click(b.getByRole('button', { name: 'Collapse JSON node' }))
+    fireEvent.click(b.getByRole('button', { name: 'Expand JSON node' }))
+    expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true')
+    fireEvent.click(b.getByRole('button', { name: 'Wrap lines' }))
+    expect(wrapped).toBe(false)
+    expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true')
+
+    fireEvent.click(a.getByRole('button', { name: 'Collapse JSON node' }))
+    fireEvent.click(a.getByRole('button', { name: 'Expand JSON node' }))
+    const toggle = a.getByRole('button', { name: 'Wrap lines' })
+    expect(toggle.getAttribute('aria-pressed')).toBe('false')
+    const contents = document.getElementById(toggle.getAttribute('aria-controls') as string)
+    expect(contents?.textContent).toBe(original)
+    fireEvent.click(a.getByRole('button', { name: 'Copy value' }))
+    await waitFor(() => { expect(writeText).toHaveBeenCalledWith(original) })
+  })
+
   it('keeps the top level open and renders expandable value previews', () => {
   it('keeps the top level open and renders expandable value previews', () => {
     render(
     render(
       <JsonTree
       <JsonTree
@@ -270,48 +425,21 @@ describe('JsonTree', () => {
     view.unmount()
     view.unmount()
   })
   })
 
 
-  it('keeps copy placement synchronized and clears stale targets', () => {
+  it('keeps the copy action on its hovered row and clears stale targets', () => {
     const view = render(<JsonTree data={{ first: { a: 1 }, second: 2 }} />)
     const view = render(<JsonTree data={{ first: { a: 1 }, second: 2 }} />)
     const root = view.container.firstElementChild as HTMLElement
     const root = view.container.firstElementChild as HTMLElement
     const tree = screen.getByRole('tree')
     const tree = screen.getByRole('tree')
     const firstRow = within(tree).getAllByRole('treeitem')[0] as HTMLElement
     const firstRow = within(tree).getAllByRole('treeitem')[0] as HTMLElement
     const secondRow = within(tree).getAllByRole('treeitem')[1] as HTMLElement
     const secondRow = within(tree).getAllByRole('treeitem')[1] as HTMLElement
 
 
-    Object.defineProperty(root, 'clientHeight', { configurable: true, value: 100 })
-    Object.defineProperty(root, 'clientWidth', { configurable: true, value: 300 })
-    vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({
-      bottom: 100,
-      height: 100,
-      left: 10,
-      right: 310,
-      top: 0,
-      width: 300,
-      x: 10,
-      y: 0,
-      toJSON: () => ({}),
-    })
-    vi.spyOn(firstRow, 'getBoundingClientRect').mockReturnValue({
-      bottom: 91,
-      height: 16,
-      left: 10,
-      right: 200,
-      top: 75,
-      width: 190,
-      x: 10,
-      y: 75,
-      toJSON: () => ({}),
-    })
-
     fireEvent.mouseOver(firstRow)
     fireEvent.mouseOver(firstRow)
     const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' })
     const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' })
-    expect((copyButton.closest('span')?.parentElement as HTMLElement).style.left).toBe('284px')
+    expect(firstRow.contains(copyButton)).toBe(true)
     fireEvent.mouseOver(copyButton)
     fireEvent.mouseOver(copyButton)
     expect(screen.getByRole('button', { name: 'Copy pretty JSON' })).toBeDefined()
     expect(screen.getByRole('button', { name: 'Copy pretty JSON' })).toBeDefined()
     fireEvent.mouseOver(firstRow)
     fireEvent.mouseOver(firstRow)
 
 
     fireEvent.scroll(root)
     fireEvent.scroll(root)
-    fireEvent.scroll(window)
-    fireEvent.resize(window)
 
 
     fireEvent.contextMenu(copyButton)
     fireEvent.contextMenu(copyButton)
     fireEvent.mouseOver(secondRow)
     fireEvent.mouseOver(secondRow)

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
-README.md: b3846ccbe6c6cb4ff2a68b74ddc3d4c219953c08
-README.zh.md: f4b788eacf0de69342d33e5f33bb966b4a1d8246
+README.md: 63bd664b902d8d40f8a05153770306d3b03dbe4e
+README.zh.md: 89db03a99ac68065211f92ff7e47307813c1ab76

+ 4 - 0
packages/client/ui-trajectory/README.md

@@ -29,10 +29,14 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit
 
 
 ### Inspecting records
 ### Inspecting records
 
 
+Calls are identified by their recorded tool name. For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Copying the original arguments retains their recorded JSON whitespace. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints.
+
 Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn.
 Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn.
 
 
 ### The timing overview
 ### The timing overview
 
 
+Historical replies retain TTFT, generation duration, and throughput when their recorded streams contain token timestamps. TTFT measures from the Step start to its first token, including output from an earlier retry attempt; an unloaded Step start or a stream without tokens leaves the corresponding metric unavailable.
+
 A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range; wheel gestures zoom the time domain; a right-button click clears the selected interval, and a right-button drag pans an already zoomed viewport. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows.
 A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range; wheel gestures zoom the time domain; a right-button click clears the selected interval, and a right-button drag pans an already zoomed viewport. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows.
 
 
 -----
 -----

+ 4 - 0
packages/client/ui-trajectory/README.zh.md

@@ -29,10 +29,14 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间
 
 
 ### 检查记录
 ### 检查记录
 
 
+调用按已记录的工具名识别。对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。复制原始参数时保留记录中 JSON 的空白。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。
+
 选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。
 选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。
 
 
 ### 时间概览
 ### 时间概览
 
 
+当记录的流包含 token 时间戳时,历史回复会保留 TTFT、生成耗时与吞吐量。TTFT 从步骤开始计量到首个 token,包括更早重试尝试中的输出;步骤起点尚未加载或流中没有 token 时,对应指标保持不可用。
+
 固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻与耗时详情。拖选区间会把记录表聚焦到该闭区间内任何时刻处于活动的记录;滚轮手势用于缩放时间域;右键单击会清除所选区间,在已放大的视口上按住右键拖动则会平移。初始视图与流式更新都停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。
 固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻与耗时详情。拖选区间会把记录表聚焦到该闭区间内任何时刻处于活动的记录;滚轮手势用于缩放时间域;右键单击会清除所选区间,在已放大的视口上按住右键拖动则会平移。初始视图与流式更新都停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。
 
 
 -----
 -----

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

@@ -57,6 +57,7 @@ export function TrajectoryCell({
   assistantMetrics: _assistantMetrics,
   assistantMetrics: _assistantMetrics,
   result: _result,
   result: _result,
   callId: _callId,
   callId: _callId,
+  toolName: _toolName,
   isError: _isError,
   isError: _isError,
   timeSeconds,
   timeSeconds,
   startedAt: _startedAt,
   startedAt: _startedAt,

+ 161 - 8
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css

@@ -137,6 +137,10 @@
   width: 122px;
   width: 122px;
 }
 }
 
 
+.eventColumn:where(:lang(zh)) {
+  width: 84px;
+}
+
 .contentColumn {
 .contentColumn {
   width: auto;
   width: auto;
 }
 }
@@ -448,6 +452,10 @@
   width: 76px;
   width: 76px;
 }
 }
 
 
+.kindSlot:where(:lang(zh)) {
+  width: 44px;
+}
+
 .kindSlot :global([role='tooltip']) {
 .kindSlot :global([role='tooltip']) {
   border: 0;
   border: 0;
   background: var(--dsw-alias-bg-layer-2);
   background: var(--dsw-alias-bg-layer-2);
@@ -1038,6 +1046,7 @@
 .detailBodySummary > .overview {
 .detailBodySummary > .overview {
   flex: 0 1 auto;
   flex: 0 1 auto;
   min-height: 0;
   min-height: 0;
+  padding-bottom: 0;
   overflow: auto;
   overflow: auto;
   overscroll-behavior: contain;
   overscroll-behavior: contain;
 }
 }
@@ -1176,13 +1185,13 @@
   display: flex;
   display: flex;
   flex: 1 1 0;
   flex: 1 1 0;
   max-height: max-content;
   max-height: max-content;
-  min-height: 28px;
+  min-height: 24px;
   flex-direction: column;
   flex-direction: column;
   overflow: hidden;
   overflow: hidden;
 }
 }
 
 
 .overviewSection + .overviewSection {
 .overviewSection + .overviewSection {
-  padding-top: 8px;
+  padding-top: 2px;
 }
 }
 
 
 .overviewHeading {
 .overviewHeading {
@@ -1191,7 +1200,7 @@
   align-items: flex-end;
   align-items: flex-end;
   box-sizing: border-box;
   box-sizing: border-box;
   width: 100%;
   width: 100%;
-  height: 28px;
+  height: 24px;
   margin: 0;
   margin: 0;
   padding: 0 0 3px 14px;
   padding: 0 0 3px 14px;
   color: var(--dsw-alias-label-secondary);
   color: var(--dsw-alias-label-secondary);
@@ -1243,13 +1252,157 @@
   background: var(--dsw-alias-bg-layer-1);
   background: var(--dsw-alias-bg-layer-1);
 }
 }
 
 
+.overviewPreview[data-scroll-more] {
+  mask-image: linear-gradient(to bottom, #000 calc(100% - 12px), transparent);
+}
+
 .overviewPreview > :last-child {
 .overviewPreview > :last-child {
   margin-bottom: 0;
   margin-bottom: 0;
   padding-bottom: 0;
   padding-bottom: 0;
 }
 }
 
 
+.programDescription {
+  flex: none;
+  margin: 0;
+  padding: 8px 14px 2px;
+  color: var(--dsw-alias-label-primary);
+  font: var(--dsw-font-xs-13);
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+
+.programIcon {
+  margin-right: 4px;
+  vertical-align: -2px;
+}
+
+.programSummary {
+  margin-left: 5px;
+  font-family: var(--dsw-font-family);
+}
+
+.detailBodyProgram,
+.programPanel {
+  display: flex;
+  min-height: 0;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.programPanel {
+  flex: 1;
+  background: var(--dsw-alias-bg-layer-1);
+}
+
+.programActions {
+  display: inline-flex;
+  flex: none;
+  height: 16px;
+  align-items: center;
+  gap: 4px;
+  margin-left: auto;
+  padding-right: 14px;
+}
+
+.programLanguage {
+  padding-right: 4px;
+  color: var(--dsw-alias-label-tertiary);
+  font: 11px/16px var(--ds-font-family-code);
+}
+
+.programAction {
+  display: inline-flex;
+  box-sizing: border-box;
+  width: 20px;
+  height: 16px;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  border: 0;
+  color: var(--dsw-alias-label-secondary);
+  background: transparent;
+  cursor: pointer;
+}
+
+.programAction:hover {
+  color: var(--dsw-alias-label-primary);
+}
+
+.programAction[aria-pressed='true'] {
+  color: var(--dsw-alias-state-business-primary);
+}
+
+.programAction[data-state='failed'] {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.programAction:focus-visible {
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: -1px;
+}
+
+.programContent {
+  min-width: 0;
+  min-height: 0;
+}
+
+.programPanel > .programContent {
+  flex: 1;
+  overflow: auto;
+  overscroll-behavior: contain;
+}
+
+.programSource {
+  margin: 0 !important;
+  border-radius: 0;
+  background: var(--dsw-alias-bg-layer-1);
+  --dsl-code-block-line-white-space: pre;
+}
+
+.programSource :where(pre) {
+  padding: 2px 14px 8px;
+  overflow: visible;
+  border-radius: 0;
+  background: var(--dsw-alias-bg-layer-1) !important;
+  font: 12px/16px var(--ds-font-family-code);
+  overflow-wrap: normal;
+  word-break: normal;
+  tab-size: 2;
+}
+
+.programSource pre code > :global(.line) {
+  box-sizing: border-box;
+  width: max-content;
+  min-width: 100%;
+}
+
+.programContent[data-wrap='true'] .programSource {
+  --dsl-code-block-line-white-space: pre-wrap;
+}
+
+.programContent[data-wrap='true'] .programSource pre code > :global(.line) {
+  width: auto;
+  overflow-wrap: anywhere;
+  word-break: break-all;
+}
+
+.programOutput {
+  margin: 0;
+  padding: 2px 14px 8px;
+  color: var(--dsw-alias-label-primary);
+  font: 12px/16px var(--ds-font-family-code);
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+
+.programError::first-line {
+  color: var(--dsw-alias-state-error-primary);
+  font-weight: 600;
+}
+
 .overviewPreview .jsonPreview > :first-child,
 .overviewPreview .jsonPreview > :first-child,
 .overviewPreview .schemaTree > :first-child {
 .overviewPreview .schemaTree > :first-child {
+  padding-top: 2px;
   padding-bottom: 0;
   padding-bottom: 0;
 }
 }
 
 
@@ -1405,7 +1558,7 @@
 }
 }
 
 
 .markdownPreview {
 .markdownPreview {
-  padding: 6px 14px 8px;
+  padding: 2px 14px 8px;
 }
 }
 
 
 .markdownPayload {
 .markdownPayload {
@@ -1435,7 +1588,7 @@
 }
 }
 
 
 .schemaPreview .schemaIntro {
 .schemaPreview .schemaIntro {
-  padding-top: 6px;
+  padding-top: 2px;
 }
 }
 
 
 .schemaName {
 .schemaName {
@@ -1482,7 +1635,7 @@
 
 
 .payloadPreview {
 .payloadPreview {
   min-height: 0;
   min-height: 0;
-  padding: 6px 14px 8px;
+  padding: 2px 14px 8px;
 }
 }
 
 
 .sourceBlocks {
 .sourceBlocks {
@@ -1733,7 +1886,7 @@
 
 
 .resultBlocksPreview {
 .resultBlocksPreview {
   min-height: 0;
   min-height: 0;
-  padding: 6px 14px 8px;
+  padding: 2px 14px 8px;
   gap: 6px;
   gap: 6px;
 }
 }
 
 
@@ -1748,7 +1901,7 @@
 }
 }
 
 
 .overviewPreview .noPayload {
 .overviewPreview .noPayload {
-  padding: 6px 14px 8px;
+  padding: 2px 14px 0;
 }
 }
 
 
 .noPayload {
 .noPayload {

+ 314 - 61
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -1,18 +1,24 @@
 /** Turn-aware trajectory event ledger with a local record inspector. */
 /** Turn-aware trajectory event ledger with a local record inspector. */
 
 
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
 import type { CSSProperties, ReactNode } from 'react'
 import type { CSSProperties, ReactNode } from 'react'
 import { useVirtualizer } from '@tanstack/react-virtual'
 import { useVirtualizer } from '@tanstack/react-virtual'
 import {
 import {
+  CodeBlock,
+  IconCheckOutline16,
   IconChevronRightOutline14,
   IconChevronRightOutline14,
+  IconCodeOutline16,
+  IconWrapLinesOutline16,
+  IconCopyOutline16,
   IconSettingsOutline16,
   IconSettingsOutline16,
   IconSparkle16,
   IconSparkle16,
   IconUserOutline16,
   IconUserOutline16,
   JsonTree,
   JsonTree,
   MarkdownText,
   MarkdownText,
   Tooltip,
   Tooltip,
+  writeClipboard,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { JsonTreeLabels, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { JsonTreeLabels, JsonTreeProps, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
 import { structuredPatch } from 'diff'
 import { structuredPatch } from 'diff'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
 import type {
@@ -30,6 +36,7 @@ import type { TrajectoryTurnModel } from './layout.ts'
 import { trajectoryPreviewText } from './trajectory-preview.ts'
 import { trajectoryPreviewText } from './trajectory-preview.ts'
 import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts'
 import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts'
 import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts'
 import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts'
+import { codeProgram, PTC_TOOL_NAME, type CodeProgram } from './code-program.ts'
 import css from './TrajectoryTable.module.css'
 import css from './TrajectoryTable.module.css'
 
 
 const BOTTOM_FOLLOW_THRESHOLD_PX = 2
 const BOTTOM_FOLLOW_THRESHOLD_PX = 2
@@ -183,6 +190,7 @@ interface ParentRecords {
 }
 }
 
 
 interface ToolCallTextParts {
 interface ToolCallTextParts {
+  program: boolean
   name: string
   name: string
   args?: string
   args?: string
 }
 }
@@ -232,8 +240,8 @@ function jsonTreeLabels(t: TrajectoryTranslate): JsonTreeLabels {
     copyCompactJson: t('copy.compactJson'),
     copyCompactJson: t('copy.compactJson'),
     copied: t('copied'),
     copied: t('copied'),
     copyFailed: t('copy.failed'),
     copyFailed: t('copy.failed'),
-    collapseNode: t('json.collapseNode'),
-    expandNode: t('json.expandNode'),
+    collapseNode: t('collapse'),
+    expandNode: t('expand'),
     copyButtonTitle: action => t('copy.optionsHint', { action }),
     copyButtonTitle: action => t('copy.optionsHint', { action }),
   }
   }
 }
 }
@@ -376,6 +384,8 @@ function AssistantTimingPanel({
 
 
 /** Props for the trajectory ledger. */
 /** Props for the trajectory ledger. */
 export interface TrajectoryTableProps {
 export interface TrajectoryTableProps {
+  /** Wrapping control shared by all JSON inspectors. */
+  stringWrapping?: JsonTreeProps['stringWrapping']
   /** Trajectory locale seat. */
   /** Trajectory locale seat. */
   t: TrajectoryTranslate
   t: TrajectoryTranslate
   /** Slot-backed durable image renderer shared with the Chat gallery. */
   /** Slot-backed durable image renderer shared with the Chat gallery. */
@@ -844,10 +854,12 @@ function RequestUsagePanel({
 function RequestOptions({
 function RequestOptions({
   options,
   options,
   preview = false,
   preview = false,
+  stringWrapping,
   t,
   t,
 }: {
 }: {
   options: AssistantRequestConfig | undefined
   options: AssistantRequestConfig | undefined
   preview?: boolean
   preview?: boolean
+  stringWrapping: JsonTreeProps['stringWrapping']
   t: TrajectoryTranslate
   t: TrajectoryTranslate
 }) {
 }) {
   if (options === undefined) {
   if (options === undefined) {
@@ -856,6 +868,8 @@ function RequestOptions({
   return (
   return (
     <JsonTree
     <JsonTree
       data={options}
       data={options}
+      stringWrapping={stringWrapping}
+      collapsedStringLines={preview ? 3 : 12}
       label={t('options.json')}
       label={t('options.json')}
       labels={jsonTreeLabels(t)}
       labels={jsonTreeLabels(t)}
       className={preview ? css.jsonPreview : css.jsonPayload}
       className={preview ? css.jsonPreview : css.jsonPayload}
@@ -886,7 +900,11 @@ function messageSourceLabel(source: unknown, t: TrajectoryTranslate): string {
   return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}`
   return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}`
 }
 }
 
 
-function MessageSource({ record, t }: { record: TableRecord; t: TrajectoryTranslate }) {
+function MessageSource({ record, stringWrapping, t }: {
+  record: TableRecord
+  stringWrapping: JsonTreeProps['stringWrapping']
+  t: TrajectoryTranslate
+}) {
   const source = record.cell.messageSource
   const source = record.cell.messageSource
   if (source === undefined) return <p className={css.noPayload}>{t('source.notRecorded')}</p>
   if (source === undefined) return <p className={css.noPayload}>{t('source.notRecorded')}</p>
   const data = typeof source === 'object' && source !== null
   const data = typeof source === 'object' && source !== null
@@ -895,6 +913,8 @@ function MessageSource({ record, t }: { record: TableRecord; t: TrajectoryTransl
   return (
   return (
     <JsonTree
     <JsonTree
       data={data}
       data={data}
+      stringWrapping={stringWrapping}
+      collapsedStringLines={12}
       label={t('source.messageJson')}
       label={t('source.messageJson')}
       labels={jsonTreeLabels(t)}
       labels={jsonTreeLabels(t)}
       className={css.jsonPayload}
       className={css.jsonPayload}
@@ -979,8 +999,11 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
   }
   }
   return [
   return [
     { id: 'overview', labelKey: 'tab.summary' },
     { id: 'overview', labelKey: 'tab.summary' },
-    ...(record.cell.inputDetail ? [{ id: 'input', labelKey: 'tab.payload' } as const] : []),
-    ...(record.cell.outputDetail ? [{ id: 'output', labelKey: 'tab.result' } as const] : []),
+    ...(record.cell.inputDetail ? [{
+      id: 'input', labelKey: codeProgram(record.cell) === undefined ? 'tab.payload' : 'code.source',
+    } as const] : []),
+    ...(record.cell.outputDetail || codeProgram(record.cell) !== undefined
+      ? [{ id: 'output', labelKey: 'tab.result' } as const] : []),
     { id: 'schema', labelKey: 'tab.schema' },
     { id: 'schema', labelKey: 'tab.schema' },
     { id: 'timing', labelKey: 'tab.timing' },
     { id: 'timing', labelKey: 'tab.timing' },
   ]
   ]
@@ -988,6 +1011,8 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
 
 
 function recordDisplayText(cell: TrajectoryCellProps, t: TrajectoryTranslate): string {
 function recordDisplayText(cell: TrajectoryCellProps, t: TrajectoryTranslate): string {
   if (isToolCallOnly(cell, t)) return ''
   if (isToolCallOnly(cell, t)) return ''
+  const program = codeProgram(cell)
+  if (program !== undefined) return `${PTC_TOOL_NAME} · ${program.description.replace(/\s+/g, ' ')}`
   if (cell.previewMarkdown !== undefined) {
   if (cell.previewMarkdown !== undefined) {
     const preview = trajectoryPreviewText(cell.previewMarkdown)
     const preview = trajectoryPreviewText(cell.previewMarkdown)
     if (cell.text === '') return preview
     if (cell.text === '') return preview
@@ -1009,13 +1034,15 @@ function recordResultText(cell: TrajectoryCellProps): string | undefined {
 }
 }
 
 
 function toolCallTextParts(
 function toolCallTextParts(
-  kind: TrajectoryCellKind,
+  cell: TrajectoryCellProps,
   text: string,
   text: string,
 ): ToolCallTextParts | undefined {
 ): ToolCallTextParts | undefined {
-  if (kind !== 'tool' && kind !== 'subtool') return undefined
+  if (cell.kind !== 'tool' && cell.kind !== 'subtool') return undefined
+  const program = cell.toolName === PTC_TOOL_NAME
   const separator = text.indexOf(' · ')
   const separator = text.indexOf(' · ')
-  if (separator === -1) return { name: text }
+  if (separator === -1) return { name: text, program }
   return {
   return {
+    program,
     name: text.slice(0, separator),
     name: text.slice(0, separator),
     args: text.slice(separator + 3),
     args: text.slice(separator + 3),
   }
   }
@@ -1048,7 +1075,7 @@ function RecordPresentation({
   const displayText = useMemo(
   const displayText = useMemo(
     () => recordDisplayText(cell, t),
     () => recordDisplayText(cell, t),
     [
     [
-      cell.kind, cell.text, cell.previewMarkdown,
+      cell.kind, cell.text, cell.toolName, cell.previewMarkdown,
       cell.inputDetail, cell.outputDetail, cell.thinkingDetail, t,
       cell.inputDetail, cell.outputDetail, cell.thinkingDetail, t,
     ],
     ],
   )
   )
@@ -1057,7 +1084,7 @@ function RecordPresentation({
     [cell.result, cell.resultPreviewMarkdown],
     [cell.result, cell.resultPreviewMarkdown],
   )
   )
   const toolCallOnly = isToolCallOnly(cell, t)
   const toolCallOnly = isToolCallOnly(cell, t)
-  const toolCallText = toolCallTextParts(cell.kind, displayText)
+  const toolCallText = toolCallTextParts(cell, displayText)
   const listDisplayText = toolCallOnly
   const listDisplayText = toolCallOnly
     ? t('record.toolCallOnly')
     ? t('record.toolCallOnly')
     : toolCallText === undefined
     : toolCallText === undefined
@@ -1087,10 +1114,11 @@ function RecordListText({
   return (
   return (
     <>
     <>
       <span className={css.toolCallNameTypeface}>
       <span className={css.toolCallNameTypeface}>
+        {toolCallText.program && <IconCodeOutline16 className={css.programIcon} size={12} />}
         {toolCallText.name || '—'}
         {toolCallText.name || '—'}
       </span>
       </span>
       {toolCallText.args !== undefined && (
       {toolCallText.args !== undefined && (
-        <span className={css.toolCallPayload}>
+        <span className={toolCallText.program ? css.programSummary : css.toolCallPayload}>
           {toolCallText.args}
           {toolCallText.args}
         </span>
         </span>
       )}
       )}
@@ -1281,8 +1309,13 @@ function ToolGlyph() {
 
 
 function ToolCatalog({
 function ToolCatalog({
   tools,
   tools,
+  stringWrapping,
   t,
   t,
-}: { tools: ConversationPromptSnapshot['tools']; t: TrajectoryTranslate }) {
+}: {
+  tools: ConversationPromptSnapshot['tools']
+  stringWrapping: JsonTreeProps['stringWrapping']
+  t: TrajectoryTranslate
+}) {
   if (tools.length === 0) return <p className={css.noPayload}>{t('record.toolsMissing')}</p>
   if (tools.length === 0) return <p className={css.noPayload}>{t('record.toolsMissing')}</p>
   return (
   return (
     <div className={css.toolCatalog}>
     <div className={css.toolCatalog}>
@@ -1300,6 +1333,7 @@ function ToolCatalog({
             )}
             )}
             <JsonTree
             <JsonTree
               data={tool.parameters}
               data={tool.parameters}
+              stringWrapping={stringWrapping}
               label={t('record.namedParametersJson', { name: tool.name })}
               label={t('record.namedParametersJson', { name: tool.name })}
               labels={jsonTreeLabels(t)}
               labels={jsonTreeLabels(t)}
               className={css.toolCatalogTree}
               className={css.toolCatalogTree}
@@ -1544,14 +1578,20 @@ function MarkdownRecordContent({
   )
   )
 }
 }
 
 
-function RecordTiming({ record, t }: { record: TableRecord; t: TrajectoryTranslate }) {
+function RecordTiming({
+  record,
+  preview = false,
+  t,
+}: { record: TableRecord; preview?: boolean; t: TrajectoryTranslate }) {
   return record.cell.kind === 'message' && record.cell.assistantMetrics !== undefined
   return record.cell.kind === 'message' && record.cell.assistantMetrics !== undefined
     ? <AssistantTimingPanel metrics={record.cell.assistantMetrics} t={t} />
     ? <AssistantTimingPanel metrics={record.cell.assistantMetrics} t={t} />
     : (
     : (
       <dl className={css.overview}>
       <dl className={css.overview}>
         <div><dt>{t('timing.started')}</dt><StartedAtValue timestamp={record.cell.startedAt ?? null} t={t} /></div>
         <div><dt>{t('timing.started')}</dt><StartedAtValue timestamp={record.cell.startedAt ?? null} t={t} /></div>
         <div><dt>{t('timing.duration')}</dt><dd>{formatElapsedSeconds(record.cell.timeSeconds, t)}</dd></div>
         <div><dt>{t('timing.duration')}</dt><dd>{formatElapsedSeconds(record.cell.timeSeconds, t)}</dd></div>
-        <div><dt>{t('timing.source')}</dt><dd>{record.cell.timeSeconds === null ? t('timing.notAvailable') : t('timing.sessionTimestamps')}</dd></div>
+        {!preview && (
+          <div><dt>{t('timing.source')}</dt><dd>{record.cell.timeSeconds === null ? t('timing.notAvailable') : t('timing.sessionTimestamps')}</dd></div>
+        )}
       </dl>
       </dl>
     )
     )
 }
 }
@@ -1560,14 +1600,16 @@ function RequestTiming({
   assistant,
   assistant,
   anchor,
   anchor,
   request,
   request,
+  preview = false,
   t,
   t,
 }: {
 }: {
   assistant: TableRecord | undefined
   assistant: TableRecord | undefined
   anchor: TableRecord | undefined
   anchor: TableRecord | undefined
   request: TrajectoryRequestNumber | undefined
   request: TrajectoryRequestNumber | undefined
+  preview?: boolean
   t: TrajectoryTranslate
   t: TrajectoryTranslate
 }) {
 }) {
-  if (assistant !== undefined) return <RecordTiming record={assistant} t={t} />
+  if (assistant !== undefined) return <RecordTiming record={assistant} preview={preview} t={t} />
   if (request?.startedAt !== undefined) {
   if (request?.startedAt !== undefined) {
     const duration = request.completedAt === null || request.completedAt === undefined
     const duration = request.completedAt === null || request.completedAt === undefined
       ? null
       ? null
@@ -1576,10 +1618,12 @@ function RequestTiming({
       <dl className={css.overview}>
       <dl className={css.overview}>
         <div><dt>{t('timing.started')}</dt><StartedAtValue timestamp={request.startedAt} t={t} /></div>
         <div><dt>{t('timing.started')}</dt><StartedAtValue timestamp={request.startedAt} t={t} /></div>
         <div><dt>{t('timing.duration')}</dt><dd>{formatElapsedSeconds(duration, t)}</dd></div>
         <div><dt>{t('timing.duration')}</dt><dd>{formatElapsedSeconds(duration, t)}</dd></div>
-        <div>
-          <dt>{t('timing.source')}</dt>
-          <dd>{duration === null ? t('timing.sessionTimestampsRunning') : t('timing.sessionTimestamps')}</dd>
-        </div>
+        {!preview && (
+          <div>
+            <dt>{t('timing.source')}</dt>
+            <dd>{duration === null ? t('timing.sessionTimestampsRunning') : t('timing.sessionTimestamps')}</dd>
+          </div>
+        )}
       </dl>
       </dl>
     )
     )
   }
   }
@@ -1599,12 +1643,14 @@ function RecordPayload({
   direction,
   direction,
   preview = false,
   preview = false,
   renderImages,
   renderImages,
+  stringWrapping,
   t,
   t,
 }: {
 }: {
   record: TableRecord
   record: TableRecord
   direction: 'input' | 'output'
   direction: 'input' | 'output'
   preview?: boolean
   preview?: boolean
   renderImages: RenderMessageImages
   renderImages: RenderMessageImages
+  stringWrapping: JsonTreeProps['stringWrapping']
   t: TrajectoryTranslate
   t: TrajectoryTranslate
 }) {
 }) {
   const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail
   const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail
@@ -1624,6 +1670,8 @@ function RecordPayload({
     return (
     return (
       <JsonTree
       <JsonTree
         data={json}
         data={json}
+        stringWrapping={stringWrapping}
+        collapsedStringLines={preview ? 3 : 12}
         label={t('record.resultJson')}
         label={t('record.resultJson')}
         labels={jsonTreeLabels(t)}
         labels={jsonTreeLabels(t)}
         className={payloadClassName}
         className={payloadClassName}
@@ -1668,6 +1716,8 @@ function RecordPayload({
     return (
     return (
       <JsonTree
       <JsonTree
         data={json}
         data={json}
+        stringWrapping={stringWrapping}
+        collapsedStringLines={preview ? 3 : 12}
         label={t(direction === 'input' ? 'record.payloadJson' : 'record.outputJson')}
         label={t(direction === 'input' ? 'record.payloadJson' : 'record.outputJson')}
         labels={jsonTreeLabels(t)}
         labels={jsonTreeLabels(t)}
         className={payloadClassName}
         className={payloadClassName}
@@ -1690,10 +1740,12 @@ function RecordPayload({
 function RecordSchema({
 function RecordSchema({
   record,
   record,
   preview = false,
   preview = false,
+  stringWrapping,
   t,
   t,
 }: {
 }: {
   record: TableRecord
   record: TableRecord
   preview?: boolean
   preview?: boolean
+  stringWrapping: JsonTreeProps['stringWrapping']
   t: TrajectoryTranslate
   t: TrajectoryTranslate
 }) {
 }) {
   if (!record.cell.schemaDetail) {
   if (!record.cell.schemaDetail) {
@@ -1711,6 +1763,8 @@ function RecordSchema({
           <h4 className={css.schemaParametersTitle}>{t('record.parameters')}</h4>
           <h4 className={css.schemaParametersTitle}>{t('record.parameters')}</h4>
           <JsonTree
           <JsonTree
             data={schema.parameters}
             data={schema.parameters}
+            stringWrapping={stringWrapping}
+            collapsedStringLines={preview ? 3 : 12}
             label={t('record.namedParametersJson', { name: schema.name })}
             label={t('record.namedParametersJson', { name: schema.name })}
             labels={jsonTreeLabels(t)}
             labels={jsonTreeLabels(t)}
             className={css.schemaTree}
             className={css.schemaTree}
@@ -1763,15 +1817,174 @@ function parseJsonContainer(value: string): object | undefined {
   }
   }
 }
 }
 
 
+function InspectorCopyButton({ text, label, t }: {
+  text: string
+  label: string
+  t: TrajectoryTranslate
+}) {
+  const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle')
+  useEffect(() => {
+    if (state === 'idle') return
+    const timer = setTimeout(() => { setState('idle') }, 1_500)
+    return () => { clearTimeout(timer) }
+  }, [state])
+  const title = state === 'idle' ? label : t(state === 'copied' ? 'copied' : 'copy.failed')
+  return (
+    <button
+      type="button"
+      className={css.programAction}
+      data-state={state}
+      aria-label={title}
+      title={title}
+      onClick={() => { void writeClipboard(text).then((ok) => { setState(ok ? 'copied' : 'failed') }) }}
+    >
+      {state === 'copied' ? <IconCheckOutline16 size={12} /> : <IconCopyOutline16 size={12} />}
+    </button>
+  )
+}
+
+function ProgramInput({ program, initialWrapped, stringWrapping, onOpen, t }: {
+  program: CodeProgram
+  initialWrapped: boolean
+  stringWrapping: JsonTreeProps['stringWrapping']
+  onOpen?: () => void
+  t: TrajectoryTranslate
+}) {
+  const contentsId = useId()
+  const [wrapped, setWrapped] = useState(initialWrapped)
+  const [showJson, setShowJson] = useState(false)
+  const actions = (
+    <span className={css.programActions}>
+      {!showJson && program.language !== undefined && (
+        <span className={css.programLanguage}>{program.language}</span>
+      )}
+      {!showJson && (
+        <button
+          type="button"
+          className={css.programAction}
+          aria-label={t('record.wrapLines')}
+          title={t('record.wrapLines')}
+          aria-pressed={wrapped}
+          aria-controls={contentsId}
+          onClick={() => {
+            const next = !wrapped
+            setWrapped(next)
+            stringWrapping?.setDefault(next)
+          }}
+        >
+          <IconWrapLinesOutline16 size={12} />
+        </button>
+      )}
+      {onOpen === undefined && (
+        <button
+          type="button"
+          className={css.programAction}
+          aria-label={t('code.originalJson')}
+          title={t('code.originalJson')}
+          aria-pressed={showJson}
+          aria-controls={contentsId}
+          onClick={() => { setShowJson(value => !value) }}
+        >
+          <svg width="12" height="12" viewBox="0 0 16 16" fill="none"
+            stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+            <path d="M6 2H5a2 2 0 0 0-2 2v2a2 2 0 0 1-2 2 2 2 0 0 1 2 2v2a2 2 0 0 0 2 2h1" />
+            <path d="M10 2h1a2 2 0 0 1 2 2v2a2 2 0 0 0 2 2 2 2 0 0 0-2 2v2a2 2 0 0 1-2 2h-1" />
+          </svg>
+        </button>
+      )}
+      <InspectorCopyButton
+        text={showJson ? program.rawInput : program.source}
+        label={t(showJson ? 'copy.json' : 'code.copySource')}
+        t={t}
+      />
+    </span>
+  )
+  const body = (
+    <div id={contentsId} className={css.programContent} data-wrap={wrapped}>
+      {showJson
+        ? <JsonTree data={program.arguments} label={t('record.parametersJson')}
+          labels={jsonTreeLabels(t)} stringWrapping={stringWrapping} />
+        : <CodeBlock code={program.source} lang={program.language} lineNumbers showHeader={false}
+          className={css.programSource} copyLabel={t('code.copySource')} copiedLabel={t('copied')} />}
+    </div>
+  )
+  return onOpen === undefined
+    ? (
+      <section className={css.programPanel}>
+        <header className={css.overviewHeading}>
+          <span>{t('code.source')}</span>
+          {actions}
+        </header>
+        {body}
+      </section>
+    )
+    : <OverviewSection label={t('code.source')} onOpen={onOpen} actions={actions}>{body}</OverviewSection>
+}
+
+function ProgramOutput({ record, stringWrapping, onOpen, t }: {
+  record: TableRecord
+  stringWrapping: JsonTreeProps['stringWrapping']
+  onOpen?: () => void
+  t: TrajectoryTranslate
+}) {
+  const output = record.cell.outputDetail
+  const json = output === undefined || record.cell.isError === true ? undefined : parseJsonContainer(output)
+  const actions = output === undefined ? undefined : (
+    <span className={css.programActions}>
+      <InspectorCopyButton text={output} label={t('code.copyOutput')} t={t} />
+    </span>
+  )
+  const body = (
+    <div className={css.programContent}>
+      {output === undefined || output === ''
+        ? <p className={css.noPayload}>{t(stateOf(record) === 'running' ? 'code.running' : 'record.noOutput')}</p>
+        : json !== undefined
+          ? <JsonTree data={json} label={t('record.outputJson')} labels={jsonTreeLabels(t)}
+            stringWrapping={stringWrapping} collapsedStringLines={onOpen === undefined ? 12 : 3} />
+          : <pre className={`${css.programOutput} ${record.cell.isError === true ? css.programError : ''}`}>{output}</pre>}
+    </div>
+  )
+  return onOpen === undefined
+    ? (
+      <section className={css.programPanel}>
+        <header className={css.overviewHeading}>
+          <span>{t('code.output')}</span>
+          {actions}
+        </header>
+        {body}
+      </section>
+    )
+    : <OverviewSection label={t('code.output')} onOpen={onOpen} actions={actions}>{body}</OverviewSection>
+}
+
 function OverviewSection({
 function OverviewSection({
   label,
   label,
   onOpen,
   onOpen,
+  actions,
   children,
   children,
 }: {
 }: {
   label: string
   label: string
   onOpen: () => void
   onOpen: () => void
+  actions?: ReactNode
   children: ReactNode
   children: ReactNode
 }) {
 }) {
+  const previewRef = useRef<HTMLDivElement>(null)
+  const [hasMore, setHasMore] = useState(false)
+  const measureOverflow = useCallback((preview: HTMLElement) => {
+    setHasMore(preview.scrollHeight - preview.clientHeight - preview.scrollTop > 1)
+  }, [])
+
+  useLayoutEffect(() => {
+    const preview = previewRef.current as HTMLDivElement
+    const measure = () => { measureOverflow(preview) }
+    measure()
+    if (typeof ResizeObserver === 'undefined') return
+    const observer = new ResizeObserver(measure)
+    observer.observe(preview)
+    for (const child of preview.children) observer.observe(child)
+    return () => { observer.disconnect() }
+  }, [children, measureOverflow])
+
   return (
   return (
     <section className={css.overviewSection}>
     <section className={css.overviewSection}>
       <h3 className={css.overviewHeading}>
       <h3 className={css.overviewHeading}>
@@ -1783,10 +1996,14 @@ function OverviewSection({
           <span>{label}</span>
           <span>{label}</span>
           <IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} />
           <IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} />
         </button>
         </button>
+        {actions}
       </h3>
       </h3>
       <div
       <div
+        ref={previewRef}
         className={`${css.overviewPreview} ${css.summaryScrollRegion}`}
         className={`${css.overviewPreview} ${css.summaryScrollRegion}`}
         data-summary-scroll-region=""
         data-summary-scroll-region=""
+        data-scroll-more={hasMore || undefined}
+        onScroll={(event) => { measureOverflow(event.currentTarget) }}
       >
       >
         {children}
         {children}
       </div>
       </div>
@@ -1802,6 +2019,7 @@ function OverviewSection({
  */
  */
 export function TrajectoryTable({
 export function TrajectoryTable({
   t,
   t,
+  stringWrapping,
   renderImages,
   renderImages,
   requestNumbers: sessionRequestNumbers,
   requestNumbers: sessionRequestNumbers,
   turns,
   turns,
@@ -1828,7 +2046,8 @@ export function TrajectoryTable({
   const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null)
   const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null)
   const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
   const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
   const [activeTab, setActiveTab] = useState<DetailTab>('overview')
   const [activeTab, setActiveTab] = useState<DetailTab>('overview')
-  const [thinkingExpanded, setThinkingExpanded] = useState(false)
+  const [codeWrappingOnOpen, setCodeWrappingOnOpen] = useState(false)
+  const [thinkingDisclosure, setThinkingDisclosure] = useState<{ recordId: string; expanded: boolean }>()
   const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
   const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
   const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
   const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
   const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
   const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
@@ -1860,6 +2079,16 @@ export function TrajectoryTable({
   const selected = selectedTemplate === undefined
   const selected = selectedTemplate === undefined
     ? undefined
     ? undefined
     : currentRecord(selectedTemplate)
     : currentRecord(selectedTemplate)
+  const selectedProgram = selected === undefined ? undefined : codeProgram(selected.cell)
+  const thinkingExpanded = thinkingDisclosure?.recordId === selectedRecordId
+    ? thinkingDisclosure.expanded
+    : selected?.cell.kind === 'message'
+      && Boolean(selected.cell.thinkingDetail?.trim())
+  const setThinkingExpanded = (expanded: boolean) => {
+    if (selectedRecordId !== null) {
+      setThinkingDisclosure({ recordId: selectedRecordId, expanded })
+    }
+  }
   const selectedIndex = selected?.cell.index ?? null
   const selectedIndex = selected?.cell.index ?? null
   useEffect(() => {
   useEffect(() => {
     onSelectedIndexChange?.(selectedIndex)
     onSelectedIndexChange?.(selectedIndex)
@@ -2060,6 +2289,7 @@ export function TrajectoryTable({
     }
     }
 
 
   const activateTab = (tab: DetailTab) => {
   const activateTab = (tab: DetailTab) => {
+    setCodeWrappingOnOpen(stringWrapping?.getDefault() ?? false)
     tabHistory.current.delete(tab)
     tabHistory.current.delete(tab)
     tabHistory.current.add(tab)
     tabHistory.current.add(tab)
     setActiveTab(tab)
     setActiveTab(tab)
@@ -2076,6 +2306,7 @@ export function TrajectoryTable({
   }
   }
 
 
   const selectRecord = useCallback((index: number) => {
   const selectRecord = useCallback((index: number) => {
+    setCodeWrappingOnOpen(stringWrapping?.getDefault() ?? false)
     const record = allRecords.find(candidate => candidate.cell.index === index)
     const record = allRecords.find(candidate => candidate.cell.index === index)
     onRecordSelect?.(index)
     onRecordSelect?.(index)
     setSelectedRequest(null)
     setSelectedRequest(null)
@@ -2085,7 +2316,7 @@ export function TrajectoryTable({
     const available = new Set(tabs.map(tab => tab.id))
     const available = new Set(tabs.map(tab => tab.id))
     const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
     const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
     setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
     setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
-  }, [allRecords, onRecordSelect])
+  }, [allRecords, onRecordSelect, stringWrapping])
   useEffect(() => {
   useEffect(() => {
     if (
     if (
       recordSelection === null
       recordSelection === null
@@ -2809,7 +3040,9 @@ export function TrajectoryTable({
             id="trajectory-detail-panel"
             id="trajectory-detail-panel"
             className={activeTab === 'overview'
             className={activeTab === 'overview'
               ? `${css.detailBody} ${css.detailBodySummary}`
               ? `${css.detailBody} ${css.detailBodySummary}`
-              : css.detailBody}
+              : selectedProgram !== undefined && (activeTab === 'input' || activeTab === 'output')
+                ? `${css.detailBody} ${css.detailBodyProgram}`
+                : css.detailBody}
             role="tabpanel"
             role="tabpanel"
             aria-labelledby={`trajectory-detail-${activeTab}`}
             aria-labelledby={`trajectory-detail-${activeTab}`}
           >
           >
@@ -2916,7 +3149,7 @@ export function TrajectoryTable({
                 <div className={css.overviewSections}>
                 <div className={css.overviewSections}>
                   {selectedRequestOptions !== undefined && (
                   {selectedRequestOptions !== undefined && (
                     <OverviewSection label={t('tab.options')} onOpen={() => { activateTab('options') }}>
                     <OverviewSection label={t('tab.options')} onOpen={() => { activateTab('options') }}>
-                      <RequestOptions options={selectedRequestOptions} preview t={t} />
+                      <RequestOptions options={selectedRequestOptions} preview stringWrapping={stringWrapping} t={t} />
                     </OverviewSection>
                     </OverviewSection>
                   )}
                   )}
                   <OverviewSection label={t('tab.usage')} onOpen={() => { activateTab('usage') }}>
                   <OverviewSection label={t('tab.usage')} onOpen={() => { activateTab('usage') }}>
@@ -2927,6 +3160,7 @@ export function TrajectoryTable({
                       assistant={selectedRequestAssistant}
                       assistant={selectedRequestAssistant}
                       anchor={selectedRequestAnchor}
                       anchor={selectedRequestAnchor}
                       request={selectedRequestInfo}
                       request={selectedRequestInfo}
+                      preview
                       t={t}
                       t={t}
                     />
                     />
                   </OverviewSection>
                   </OverviewSection>
@@ -2934,7 +3168,7 @@ export function TrajectoryTable({
               </>
               </>
             )}
             )}
             {selectedRequestInfo !== undefined && activeTab === 'options' && (
             {selectedRequestInfo !== undefined && activeTab === 'options' && (
-              <RequestOptions options={selectedRequestOptions} t={t} />
+              <RequestOptions options={selectedRequestOptions} stringWrapping={stringWrapping} t={t} />
             )}
             )}
             {selectedRequestInfo !== undefined && activeTab === 'usage' && (
             {selectedRequestInfo !== undefined && activeTab === 'usage' && (
               <RequestUsagePanel
               <RequestUsagePanel
@@ -2970,7 +3204,7 @@ export function TrajectoryTable({
                 )
                 )
             )}
             )}
             {selectedPrompt !== undefined && activeTab === 'tools' && (
             {selectedPrompt !== undefined && activeTab === 'tools' && (
-              <ToolCatalog tools={selectedPrompt.tools} t={t} />
+              <ToolCatalog tools={selectedPrompt.tools} stringWrapping={stringWrapping} t={t} />
             )}
             )}
             {!promptSelected
             {!promptSelected
               && selected?.cell.kind === 'compacted'
               && selected?.cell.kind === 'compacted'
@@ -3020,6 +3254,9 @@ export function TrajectoryTable({
               && selectedState !== undefined
               && selectedState !== undefined
               && activeTab === 'overview' && (
               && activeTab === 'overview' && (
               <>
               <>
+                {selectedProgram !== undefined && selectedProgram.description !== '' && (
+                  <p className={css.programDescription}>{selectedProgram.description}</p>
+                )}
                 <dl
                 <dl
                   className={`${css.overview} ${css.summaryScrollRegion}`}
                   className={`${css.overview} ${css.summaryScrollRegion}`}
                   data-summary-scroll-region=""
                   data-summary-scroll-region=""
@@ -3111,40 +3348,50 @@ export function TrajectoryTable({
                   )}
                   )}
                 </dl>
                 </dl>
                 <div className={css.overviewSections}>
                 <div className={css.overviewSections}>
-                  {isMarkdownRecord(selected)
+                  {selectedProgram !== undefined
                     ? (
                     ? (
                       <>
                       <>
-                        <OverviewSection label={t('tab.preview')} onOpen={() => { activateTab('rendered') }}>
-                          <MarkdownRecordContent
-                            record={selected}
-                            renderImages={renderImages}
-                            rendered
-                            preview
-                            thinkingExpanded={thinkingExpanded}
-                            onThinkingExpandedChange={setThinkingExpanded}
-                            onOpenCall={openCallSummary}
-                            t={t}
-                          />
-                        </OverviewSection>
+                        <ProgramInput key={`preview:${selectedRecordId}`} program={selectedProgram}
+                          initialWrapped={codeWrappingOnOpen}
+                          stringWrapping={stringWrapping} onOpen={() => { activateTab('input') }} t={t} />
+                        <ProgramOutput record={selected} stringWrapping={stringWrapping}
+                          onOpen={() => { activateTab('output') }} t={t} />
                       </>
                       </>
                     )
                     )
-                    : (
-                      <>
-                        {selected.cell.inputDetail && (
-                          <OverviewSection label={t('tab.payload')} onOpen={() => { activateTab('input') }}>
-                            <RecordPayload record={selected} direction="input" preview renderImages={renderImages} t={t} />
+                    : isMarkdownRecord(selected)
+                      ? (
+                        <>
+                          <OverviewSection label={t('tab.preview')} onOpen={() => { activateTab('rendered') }}>
+                            <MarkdownRecordContent
+                              record={selected}
+                              renderImages={renderImages}
+                              rendered
+                              preview
+                              thinkingExpanded={thinkingExpanded}
+                              onThinkingExpandedChange={setThinkingExpanded}
+                              onOpenCall={openCallSummary}
+                              t={t}
+                            />
                           </OverviewSection>
                           </OverviewSection>
-                        )}
-                        {selected.cell.outputDetail && (
-                          <OverviewSection label={t('tab.result')} onOpen={() => { activateTab('output') }}>
-                            <RecordPayload record={selected} direction="output" preview renderImages={renderImages} t={t} />
+                        </>
+                      )
+                      : (
+                        <>
+                          {selected.cell.inputDetail && (
+                            <OverviewSection label={t('tab.payload')} onOpen={() => { activateTab('input') }}>
+                              <RecordPayload record={selected} direction="input" preview renderImages={renderImages} stringWrapping={stringWrapping} t={t} />
+                            </OverviewSection>
+                          )}
+                          {selected.cell.outputDetail && (
+                            <OverviewSection label={t('tab.result')} onOpen={() => { activateTab('output') }}>
+                              <RecordPayload record={selected} direction="output" preview renderImages={renderImages} stringWrapping={stringWrapping} t={t} />
+                            </OverviewSection>
+                          )}
+                          <OverviewSection label={t('tab.schema')} onOpen={() => { activateTab('schema') }}>
+                            <RecordSchema record={selected} preview stringWrapping={stringWrapping} t={t} />
                           </OverviewSection>
                           </OverviewSection>
-                        )}
-                        <OverviewSection label={t('tab.schema')} onOpen={() => { activateTab('schema') }}>
-                          <RecordSchema record={selected} preview t={t} />
-                        </OverviewSection>
-                      </>
-                    )}
+                        </>
+                      )}
                   {selectedAssistantRequestTarget !== undefined && (
                   {selectedAssistantRequestTarget !== undefined && (
                     <OverviewSection
                     <OverviewSection
                       label={t('timing.request')}
                       label={t('timing.request')}
@@ -3152,12 +3399,12 @@ export function TrajectoryTable({
                         selectRequest(selectedAssistantRequestTarget, 'timing')
                         selectRequest(selectedAssistantRequestTarget, 'timing')
                       }}
                       }}
                     >
                     >
-                      <RecordTiming record={selected} t={t} />
+                      <RecordTiming record={selected} preview t={t} />
                     </OverviewSection>
                     </OverviewSection>
                   )}
                   )}
                   {(selected.cell.kind === 'tool' || selected.cell.kind === 'subtool') && (
                   {(selected.cell.kind === 'tool' || selected.cell.kind === 'subtool') && (
                     <OverviewSection label={t('tab.timing')} onOpen={() => { activateTab('timing') }}>
                     <OverviewSection label={t('tab.timing')} onOpen={() => { activateTab('timing') }}>
-                      <RecordTiming record={selected} t={t} />
+                      <RecordTiming record={selected} preview t={t} />
                     </OverviewSection>
                     </OverviewSection>
                   )}
                   )}
                 </div>
                 </div>
@@ -3186,16 +3433,22 @@ export function TrajectoryTable({
               />
               />
             )}
             )}
             {!promptSelected && selected !== undefined && activeTab === 'source' && (
             {!promptSelected && selected !== undefined && activeTab === 'source' && (
-              <MessageSource record={selected} t={t} />
+              <MessageSource record={selected} stringWrapping={stringWrapping} t={t} />
             )}
             )}
             {!promptSelected && selected !== undefined && activeTab === 'input' && (
             {!promptSelected && selected !== undefined && activeTab === 'input' && (
-              <RecordPayload record={selected} direction="input" renderImages={renderImages} t={t} />
+              selectedProgram === undefined
+                ? <RecordPayload record={selected} direction="input" renderImages={renderImages} stringWrapping={stringWrapping} t={t} />
+                : <ProgramInput key={`input:${selectedRecordId}`} program={selectedProgram}
+                  initialWrapped={codeWrappingOnOpen}
+                  stringWrapping={stringWrapping} t={t} />
             )}
             )}
             {!promptSelected && selected !== undefined && activeTab === 'output' && (
             {!promptSelected && selected !== undefined && activeTab === 'output' && (
-              <RecordPayload record={selected} direction="output" renderImages={renderImages} t={t} />
+              selectedProgram === undefined
+                ? <RecordPayload record={selected} direction="output" renderImages={renderImages} stringWrapping={stringWrapping} t={t} />
+                : <ProgramOutput record={selected} stringWrapping={stringWrapping} t={t} />
             )}
             )}
             {!promptSelected && selected !== undefined && activeTab === 'schema' && (
             {!promptSelected && selected !== undefined && activeTab === 'schema' && (
-              <RecordSchema record={selected} t={t} />
+              <RecordSchema record={selected} stringWrapping={stringWrapping} t={t} />
             )}
             )}
             {!promptSelected && selected !== undefined && activeTab === 'timing' && (
             {!promptSelected && selected !== undefined && activeTab === 'timing' && (
               <RecordTiming record={selected} t={t} />
               <RecordTiming record={selected} t={t} />

+ 8 - 1
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -7,6 +7,7 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { JsonTreeProps } from '@deepseek-ai/dsh-client-ui-primitives'
 import {
 import {
   TrajectoryTable,
   TrajectoryTable,
   type TrajectoryRequestNumber,
   type TrajectoryRequestNumber,
@@ -74,6 +75,8 @@ function partialStructureSignature(partial: TrajectorySnapshot['partial']): stri
 
 
 /** Session-bound controls not already supplied by the conversation view slot. */
 /** Session-bound controls not already supplied by the conversation view slot. */
 export interface TrajectoryViewInjected {
 export interface TrajectoryViewInjected {
+  /** Shared wrapping preference read by expansion handlers. */
+  jsonStringWrapping?: Omit<NonNullable<JsonTreeProps['stringWrapping']>, 'label'>
   hooks: {
   hooks: {
     duration: SnapshotStore<boolean>
     duration: SnapshotStore<boolean>
   }
   }
@@ -128,7 +131,7 @@ function addUsage(
 
 
 export function TrajectoryView({
 export function TrajectoryView({
   useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration,
   useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration,
-  viewRequest, completeViewRequest, renderSlot, t,
+  viewRequest, completeViewRequest, renderSlot, t, jsonStringWrapping,
 }: ConvViewProps
 }: ConvViewProps
   & PropsRenderSlots<'conversation.trajectory.images'>
   & PropsRenderSlots<'conversation.trajectory.images'>
   & InjectFace<TrajectoryViewInjected>
   & InjectFace<TrajectoryViewInjected>
@@ -541,6 +544,10 @@ export function TrajectoryView({
       <div className={css.ledger}>
       <div className={css.ledger}>
         <TrajectoryTable
         <TrajectoryTable
           t={t}
           t={t}
+          stringWrapping={jsonStringWrapping === undefined ? undefined : {
+            ...jsonStringWrapping,
+            label: t('record.wrapLines'),
+          }}
           renderImages={renderImages}
           renderImages={renderImages}
           requestNumbers={requestNumbers}
           requestNumbers={requestNumbers}
           turns={timelineTurns}
           turns={timelineTurns}

+ 62 - 0
packages/client/ui-trajectory/src/client/code-program.ts

@@ -0,0 +1,62 @@
+/** Read replayable PTC source and language hints from recorded tool arguments and schemas. */
+import type { TrajectoryCellProps } from './trajectory-record.ts'
+
+/** Recorded name of the programmatic tool-calling entry point. */
+export const PTC_TOOL_NAME = 'run_code'
+
+/** Validated source and original arguments for one recorded run_code call. */
+export interface CodeProgram {
+  rawInput: string
+  source: string
+  description: string
+  arguments: Record<string, unknown>
+  language: 'typescript' | 'python' | undefined
+}
+
+function record(value: unknown): Record<string, unknown> | undefined {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+    ? value as Record<string, unknown>
+    : undefined
+}
+
+function parseRecord(value: string | undefined): Record<string, unknown> | undefined {
+  if (value === undefined) return undefined
+  try {
+    return record(JSON.parse(value))
+  } catch {
+    return undefined
+  }
+}
+
+function recordedLanguage(schemaRaw: string | undefined): CodeProgram['language'] {
+  const schema = parseRecord(schemaRaw)
+  const properties = record(record(schema?.parameters)?.properties)
+  const description = record(properties?.code)?.description
+  if (typeof description !== 'string') return undefined
+  const typescript = /\bTypeScript\b/i.test(description)
+  const python = /\bPython\b/i.test(description)
+  if (typescript === python) return undefined
+  return typescript ? 'typescript' : 'python'
+}
+
+/**
+ * Resolve a PTC program without guessing its language from source or current runtime settings.
+ * @param cell - Recorded tool arguments and the schema visible at call time.
+ * @returns The program, or undefined for another tool or unsupported arguments.
+ */
+export function codeProgram(cell: TrajectoryCellProps): CodeProgram | undefined {
+  if (cell.kind !== 'tool' && cell.kind !== 'subtool') return undefined
+  if (cell.toolName !== PTC_TOOL_NAME || cell.inputDetail === undefined) return undefined
+  const args = parseRecord(cell.inputDetail)
+  if (typeof args?.code !== 'string') return undefined
+  if (args.description !== undefined && typeof args.description !== 'string') return undefined
+  return {
+    rawInput: cell.inputDetail,
+    source: args.code,
+    description: typeof args.description === 'string' && args.description.trim() !== ''
+      ? args.description
+      : args.code.split(/\r?\n/).find(line => line.trim() !== '')?.trim() ?? '',
+    arguments: args,
+    language: recordedLanguage(cell.schemaDetail),
+  }
+}

+ 6 - 0
packages/client/ui-trajectory/src/client/index.ts

@@ -15,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import { createTrajectoryDurationStore } from './duration-store.ts'
 import { createTrajectoryDurationStore } from './duration-store.ts'
+import { createTrajectoryStringWrappingStore } from './string-wrapping-store.ts'
 import { en, NS, zh } from './locales.ts'
 import { en, NS, zh } from './locales.ts'
 import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
 import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
 import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
 import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
@@ -64,6 +65,7 @@ export function apply(ctx: Context): void {
   // re-registration.
   // re-registration.
   const t = ctx.locale.bind(NS)
   const t = ctx.locale.bind(NS)
   const duration = createTrajectoryDurationStore()
   const duration = createTrajectoryDurationStore()
+  const stringWrapping = createTrajectoryStringWrappingStore()
   registerTrajectoryMessageDefinitions(ctx)
   registerTrajectoryMessageDefinitions(ctx)
   registerTrajectoryRequestHeaderDefinition(ctx)
   registerTrajectoryRequestHeaderDefinition(ctx)
   registerTrajectoryAssistantDefinition(ctx)
   registerTrajectoryAssistantDefinition(ctx)
@@ -91,6 +93,10 @@ export function apply(ctx: Context): void {
       const trajectory = ctx.uiConversation.binding(sessionId).target('trajectory')
       const trajectory = ctx.uiConversation.binding(sessionId).target('trajectory')
       return {
       return {
         hooks: { duration },
         hooks: { duration },
+        jsonStringWrapping: {
+          getDefault: () => stringWrapping.getSnapshot(),
+          setDefault: (value) => { stringWrapping.set(value) },
+        },
         loadOlder: async () => {
         loadOlder: async () => {
           const before = trajectory.getSnapshot()
           const before = trajectory.getSnapshot()
           await session.loadOlder()
           await session.loadOlder()

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

@@ -1062,8 +1062,9 @@ function expandSubCalls(
 function summarizeCall(
 function summarizeCall(
   name: string,
   name: string,
   argsRaw: string,
   argsRaw: string,
-): Pick<TrajectoryCellProps, 'text' | 'previewMarkdown'> {
+): Pick<TrajectoryCellProps, 'text' | 'previewMarkdown' | 'toolName'> {
   return {
   return {
+    toolName: name,
     text: name,
     text: name,
     ...(argsRaw === '' ? {} : { previewMarkdown: argsRaw }),
     ...(argsRaw === '' ? {} : { previewMarkdown: argsRaw }),
   }
   }

+ 15 - 1
packages/client/ui-trajectory/src/client/locales.ts

@@ -37,7 +37,7 @@ export const zh = {
   'turn.label': '第 {turn} 轮',
   'turn.label': '第 {turn} 轮',
   'section.betweenTurns': '轮次之间',
   'section.betweenTurns': '轮次之间',
   'group.message': '消息',
   'group.message': '消息',
-  'group.step': '步骤 {step}',
+  'group.step': '第 {step} 步',
   'group.compaction': '压缩 {seq}',
   'group.compaction': '压缩 {seq}',
   'status.failed': '失败',
   'status.failed': '失败',
   'status.pending': '等待中',
   'status.pending': '等待中',
@@ -114,6 +114,13 @@ export const zh = {
   'record.payloadJson': '参数 JSON',
   'record.payloadJson': '参数 JSON',
   'record.outputJson': '结果 JSON',
   'record.outputJson': '结果 JSON',
   'record.thinking': '思考',
   'record.thinking': '思考',
+  'record.wrapLines': '自动换行',
+  'code.source': '代码',
+  'code.output': '输出',
+  'code.copySource': '复制代码',
+  'code.copyOutput': '复制输出',
+  'code.originalJson': '原始 JSON',
+  'code.running': '执行中…',
   'record.systemPromptMissing': '本次请求没有系统提示词',
   'record.systemPromptMissing': '本次请求没有系统提示词',
   'record.toolsMissing': '本次请求没有工具',
   'record.toolsMissing': '本次请求没有工具',
   'record.systemPrompt': '系统提示词',
   'record.systemPrompt': '系统提示词',
@@ -307,6 +314,13 @@ export const en: Record<TrajectoryKey, string> = {
   'record.payloadJson': 'Payload JSON',
   'record.payloadJson': 'Payload JSON',
   'record.outputJson': 'Result JSON',
   'record.outputJson': 'Result JSON',
   'record.thinking': 'Thinking',
   'record.thinking': 'Thinking',
+  'record.wrapLines': 'Wrap lines',
+  'code.source': 'Code',
+  'code.output': 'Output',
+  'code.copySource': 'Copy code',
+  'code.copyOutput': 'Copy output',
+  'code.originalJson': 'Original JSON',
+  'code.running': 'Running…',
   'record.systemPromptMissing': 'No system prompt in this request',
   'record.systemPromptMissing': 'No system prompt in this request',
   'record.toolsMissing': 'No tools in this request',
   'record.toolsMissing': 'No tools in this request',
   'record.systemPrompt': 'System Prompt',
   'record.systemPrompt': 'System Prompt',

+ 13 - 0
packages/client/ui-trajectory/src/client/string-wrapping-store.ts

@@ -0,0 +1,13 @@
+import {
+  createSnapshotStore, type SnapshotStore,
+} from '@deepseek-ai/dsh-client-store'
+
+/**
+ * Create the browser-wide default for expanded JSON strings.
+ * @returns A persisted preference sampled only when a string is expanded.
+ */
+export function createTrajectoryStringWrappingStore(): SnapshotStore<boolean> {
+  return createSnapshotStore(false, {
+    persist: { name: 'dsh.trajectory.jsonStringWrapping' },
+  })
+}

+ 19 - 2
packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts

@@ -5,6 +5,7 @@ import type {
   PartialAssistant, RequestView,
   PartialAssistant, RequestView,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import { assistantStreamFirstTokenTime } from '@deepseek-ai/dsh-llm/assistant-stream'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import { trajectoryNode } from './trajectory-definition-common.ts'
 import { trajectoryNode } from './trajectory-definition-common.ts'
 import {
 import {
@@ -191,6 +192,17 @@ function updateChunk(
   }
   }
 }
 }
 
 
+/** The Step retains its first token across live chunks and settled retry attempts. */
+function settleTiming(
+  state: AssistantState,
+  event: SessionEvent<'assistant/message' | 'assistant/attempt'>,
+): AssistantState {
+  return {
+    ...state,
+    firstTokenTime: state.firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream),
+  }
+}
+
 function settleMessage(
 function settleMessage(
   state: AssistantState,
   state: AssistantState,
   match: ConversationMatch,
   match: ConversationMatch,
@@ -198,7 +210,7 @@ function settleMessage(
 ): AssistantState {
 ): AssistantState {
   const blocks = toAssistantBlocks(event.data.message.content)
   const blocks = toAssistantBlocks(event.data.message.content)
   return {
   return {
-    ...state,
+    ...settleTiming(state, event),
     sawChunk: false,
     sawChunk: false,
     blocks,
     blocks,
     visibleBlocks: countVisibleBlocks(blocks),
     visibleBlocks: countVisibleBlocks(blocks),
@@ -226,6 +238,9 @@ function fallbackState(context: ConversationNodeContext<AssistantState>): Assist
     if (event.type === 'assistant/live-chunk') {
     if (event.type === 'assistant/live-chunk') {
       state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
       state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
       state = updateChunk(state, event.data.chunk, event.seq, event.time)
       state = updateChunk(state, event.data.chunk, event.seq, event.time)
+    } else if (event.type === 'assistant/attempt') {
+      state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
+      state = settleTiming(state, event)
     } else if (event.type === 'assistant/message') {
     } else if (event.type === 'assistant/message') {
       state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
       state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
       state = settleMessage(state, match, event)
       state = settleMessage(state, match, event)
@@ -325,6 +340,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState>
     }
     }
     if (event.type === 'assistant/live-chunk'
     if (event.type === 'assistant/live-chunk'
       || event.type === 'assistant/message'
       || event.type === 'assistant/message'
+      || event.type === 'assistant/attempt'
       || event.type === 'llm/retry'
       || event.type === 'llm/retry'
       || event.type === 'step/end') {
       || event.type === 'step/end') {
       return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
       return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
@@ -348,6 +364,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState>
       return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time)
       return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time)
     }
     }
     if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event)
     if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event)
+    if (match.event.type === 'assistant/attempt') return settleTiming(context.state, match.event)
     if (match.event.type === 'step/end') return { ...context.state, stepEnd: match }
     if (match.event.type === 'step/end') return { ...context.state, stepEnd: match }
     if (match.event.type !== 'llm/retry') return context.state
     if (match.event.type !== 'llm/retry') return context.state
     const data = match.event.data
     const data = match.event.data
@@ -372,7 +389,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState>
     }
     }
   },
   },
   publication: (match) => {
   publication: (match) => {
-    if (match.event.type === 'step/start') return 'none'
+    if (match.event.type === 'step/start' || match.event.type === 'assistant/attempt') return 'none'
     if (match.event.type !== 'assistant/live-chunk') return 'immediate'
     if (match.event.type !== 'assistant/live-chunk') return 'immediate'
     const type = match.event.data.chunk.type
     const type = match.event.data.chunk.type
     return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
     return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'

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

@@ -80,6 +80,8 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
   resultPreviewMarkdown?: string
   resultPreviewMarkdown?: string
   /** Tool call id used to link message source blocks to tool records. */
   /** Tool call id used to link message source blocks to tool records. */
   callId?: string
   callId?: string
+  /** Recorded tool name; independent of the display summary. */
+  toolName?: string
   /** Tool-only result failure state. */
   /** Tool-only result failure state. */
   isError?: boolean
   isError?: boolean
   /** Own duration in seconds, or `null` when no duration is known. */
   /** Own duration in seconds, or `null` when no duration is known. */

+ 116 - 0
packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx

@@ -0,0 +1,116 @@
+// @vitest-environment jsdom
+/** PTC source, output, and independent wrapping through the trajectory inspector. */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react'
+import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
+import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts'
+import { t } from './locale.client.ts'
+
+afterEach(() => {
+  cleanup()
+  vi.restoreAllMocks()
+  vi.unstubAllGlobals()
+})
+
+const SOURCE = 'const value = await tools.bash({ command: "pwd" })\nreturn value\n'
+const ARGS = JSON.stringify({ code: SOURCE, description: 'Read the working directory' })
+const CELL: TrajectoryCellProps = {
+  index: 1, kind: 'tool', toolName: 'run_code', text: `run_code · ${ARGS}`, callId: 'code-1',
+  inputDetail: ARGS, outputDetail: 'printed line\n42', result: 'printed line', timeSeconds: 0.5,
+}
+
+function Harness({ cell = CELL, getDefault = () => false, setDefault = () => {} }: {
+  cell?: TrajectoryCellProps
+  getDefault?: () => boolean
+  setDefault?: (value: boolean) => void
+}) {
+  return <TrajectoryTable
+    turns={[{ turn: 1, groups: [{ title: 'Step 1', cells: [cell] }] }]}
+    renderImages={() => null} t={t}
+    stringWrapping={{ label: 'Wrap lines', getDefault, setDefault }}
+    collapsedTurns={new Set()} onToggleTurn={() => {}}
+    collapsedAssistants={new Set()} onToggleAssistant={() => {}}
+  />
+}
+
+describe('PTC trajectory inspector', () => {
+  it('shows the description, numbered source, original arguments, and verbatim output', () => {
+    const view = render(<Harness />)
+    const ui = within(view.container)
+    fireEvent.click(ui.getByRole('row', { name: /run_code Read the working directory/ }))
+    expect(ui.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Summary', 'Code', 'Result', 'Schema', 'Timing'])
+    expect(ui.getByRole('tabpanel').textContent).toContain('Read the working directory')
+    expect(view.container.querySelector('[data-line-numbers] pre')?.textContent).toBe(SOURCE.trimEnd())
+    expect(ui.getByRole('tabpanel').textContent).toContain('printed line\n42')
+    fireEvent.click(ui.getByRole('button', { name: 'Code' }))
+    expect(ui.getByRole('tab', { name: 'Code' }).getAttribute('aria-selected')).toBe('true')
+    fireEvent.click(ui.getByRole('button', { name: 'Original JSON' }))
+    expect(ui.getByRole('tree', { name: 'parameters JSON' }).textContent).toContain('description:')
+    fireEvent.click(ui.getByRole('button', { name: 'Original JSON' }))
+    expect(view.container.querySelector('[data-line-numbers] pre')?.textContent).toBe(SOURCE.trimEnd())
+  })
+
+  it('copies exact source, raw JSON, and output with feedback', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    vi.stubGlobal('navigator', { clipboard: { writeText } })
+    const view = render(<Harness />)
+    const ui = within(view.container)
+    fireEvent.click(ui.getByRole('row', { name: /run_code Read/ }))
+    fireEvent.click(ui.getByRole('button', { name: 'Copy code' }))
+    await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(SOURCE) })
+    await waitFor(() => { expect(ui.getByRole('button', { name: 'Copied' })).toBeTruthy() })
+    fireEvent.click(ui.getByRole('button', { name: 'Copy output' }))
+    await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('printed line\n42') })
+    fireEvent.click(ui.getByRole('tab', { name: 'Code' }))
+    fireEvent.click(ui.getByRole('button', { name: 'Original JSON' }))
+    fireEvent.click(ui.getByRole('button', { name: 'Copy JSON' }))
+    await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(ARGS) })
+  })
+
+  it('samples the last wrapping choice on open without changing another open inspector', () => {
+    let preference = false
+    const props = { getDefault: () => preference, setDefault: (value: boolean) => { preference = value } }
+    const first = render(<Harness {...props} />)
+    const second = render(<Harness {...props} />)
+    const a = within(first.container)
+    const b = within(second.container)
+    fireEvent.click(a.getByRole('row', { name: /run_code Read/ }))
+    fireEvent.click(b.getByRole('row', { name: /run_code Read/ }))
+    fireEvent.click(a.getByRole('button', { name: 'Wrap lines' }))
+    expect(preference).toBe(true)
+    expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true')
+    expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false')
+    fireEvent.click(b.getByRole('tab', { name: 'Code' }))
+    expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true')
+    fireEvent.click(b.getByRole('button', { name: 'Wrap lines' }))
+    expect(preference).toBe(false)
+    expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true')
+    fireEvent.click(a.getByRole('button', { name: 'Close details' }))
+    fireEvent.click(a.getByRole('row', { name: /run_code Read/ }))
+    expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false')
+  })
+
+  it('shows pending execution and keeps a failed run’s captured output', () => {
+    const pending = { ...CELL, timeSeconds: null }
+    delete pending.result
+    delete pending.outputDetail
+    const view = render(<Harness cell={pending} />)
+    const ui = within(view.container)
+    fireEvent.click(ui.getByRole('row', { name: /run_code Read/ }))
+    expect(ui.getByRole('tabpanel').textContent).toContain('Running…')
+    view.rerender(<Harness cell={{ ...CELL, isError: true, outputDetail: 'Error: execution failed\nCaptured output:\nstep one' }} />)
+    expect(ui.getByRole('tabpanel').textContent).toContain('Failed')
+    expect(ui.getByRole('tabpanel').textContent).toContain('Error: execution failed\nCaptured output:\nstep one')
+  })
+
+  it('uses a tree for complete JSON output and generic input for malformed arguments', () => {
+    const view = render(<Harness cell={{ ...CELL, outputDetail: '{"passed":12,"failed":0}' }} />)
+    const ui = within(view.container)
+    fireEvent.click(ui.getByRole('row', { name: /run_code Read/ }))
+    expect(ui.getByRole('tree', { name: 'Result JSON' }).textContent).toContain('passed:12')
+    view.rerender(<Harness cell={{ ...CELL, inputDetail: '{"code":3}' }} />)
+    expect(ui.getByRole('tab', { name: 'Payload' })).toBeTruthy()
+    expect(ui.queryByRole('tab', { name: 'Code' })).toBeNull()
+    expect(ui.getByRole('tree', { name: 'Payload JSON' }).textContent).toContain('code:3')
+  })
+})

+ 70 - 0
packages/client/ui-trajectory/tests/code-program.client.spec.ts

@@ -0,0 +1,70 @@
+/** Recorded PTC argument validation and historical language selection. */
+import { describe, expect, it } from 'vitest'
+import { codeProgram } from '../src/client/code-program.ts'
+import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts'
+
+const CELL: TrajectoryCellProps = {
+  index: 1, kind: 'tool', toolName: 'run_code', text: 'run_code · {}', timeSeconds: 1,
+  inputDetail: JSON.stringify({ code: 'return 42\n', description: 'Compute the answer' }),
+}
+
+describe('recorded code programs', () => {
+  it('keeps the program and arguments verbatim', () => {
+    expect(codeProgram(CELL)).toEqual({
+      rawInput: CELL.inputDetail,
+      source: 'return 42\n', description: 'Compute the answer',
+      arguments: { code: 'return 42\n', description: 'Compute the answer' }, language: undefined,
+    })
+  })
+
+  it.each([
+    ['The program: the body of an async TypeScript function.', 'typescript'],
+    ['The program: the body of an async Python function.', 'python'],
+    ['A TypeScript or Python program.', undefined],
+    ['An executable program.', undefined],
+  ])('uses the recorded parameter description %s', (description, language) => {
+    const schemaDetail = JSON.stringify({ parameters: { properties: { code: { description } } } })
+    expect(codeProgram({ ...CELL, schemaDetail })?.language).toBe(language)
+  })
+
+  it.each([undefined, '{', '{}', 'null', '[]'])('keeps missing or unusable schemas plain: %s', (schemaDetail) => {
+    expect(codeProgram({ ...CELL, ...(schemaDetail === undefined ? {} : { schemaDetail }) })?.language).toBeUndefined()
+  })
+
+  it('uses the first nonempty source line when a description is unavailable', () => {
+    const source = '\n  const x = 1\nreturn x\n'
+    for (const description of [undefined, '', '   ']) {
+      expect(codeProgram({ ...CELL, inputDetail: JSON.stringify({ code: source, description }) }))
+        .toMatchObject({ source, description: 'const x = 1' })
+    }
+  })
+
+  it('retains empty source and unknown argument fields for JSON inspection', () => {
+    expect(codeProgram({ ...CELL, inputDetail: '{"code":"","extra":true}' }))
+      .toMatchObject({ source: '', description: '', arguments: { code: '', extra: true } })
+  })
+
+  it.each([undefined, '{', '[]', 'null', '42', '{}', '{"code":42}', '{"code":"x","description":[]}' ])(
+    'declines incomplete or unsupported arguments: %s', (inputDetail) => {
+      const cell = { ...CELL }
+      if (inputDetail === undefined) delete cell.inputDetail
+      else cell.inputDetail = inputDetail
+      expect(codeProgram(cell)).toBeUndefined()
+    },
+  )
+
+  it('uses recorded tool metadata independently of display text', () => {
+    expect(codeProgram({ ...CELL, text: 'Execute program' })?.source).toBe('return 42\n')
+    const withoutName = { ...CELL }
+    delete withoutName.toolName
+    expect(codeProgram(withoutName)).toBeUndefined()
+    const rawInput = ' { "code": "return 42" }\n'
+    expect(codeProgram({ ...CELL, inputDetail: rawInput })?.rawInput).toBe(rawInput)
+  })
+
+  it('does not specialize other tools or non-tool records', () => {
+    expect(codeProgram({ ...CELL, kind: 'message' })).toBeUndefined()
+    expect(codeProgram({ ...CELL, toolName: 'run_code_extra' })).toBeUndefined()
+    expect(codeProgram({ ...CELL, kind: 'subtool' })?.source).toBe('return 42\n')
+  })
+})

+ 9 - 2
packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts

@@ -335,13 +335,20 @@ describe('Trajectory conversation Definitions', () => {
     const finalizedPacked = snapshot(assembler(finalizedInputs))
     const finalizedPacked = snapshot(assembler(finalizedInputs))
     expect(finalizedPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({
     expect(finalizedPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({
       blocks: [{ kind: 'text', text: 'done' }],
       blocks: [{ kind: 'text', text: 'done' }],
-      timing: { firstTokenTime: null },
+      timing: { firstTokenTime: 3_000 },
     })
     })
     expect(finalizedPacked.requests).toMatchObject([{
     expect(finalizedPacked.requests).toMatchObject([{
       purpose: 'assistant',
       purpose: 'assistant',
       retry: 1,
       retry: 1,
     }])
     }])
 
 
+    const windowed = assembler(finalizedInputs.slice(2), true)
+    const assistant = () => snapshot(windowed).eventNodes.find(node => node.kind === 'assistant')
+    expect(assistant()).toMatchObject({ timing: { stepStartTime: null, firstTokenTime: 3_000 } })
+    windowed.prepend(finalizedInputs.slice(0, 2), false)
+    windowed.flush()
+    expect(assistant()).toEqual(finalizedPacked.eventNodes.find(node => node.kind === 'assistant'))
+
     const namedToolHistory = [
     const namedToolHistory = [
       at(40, 'turn/start', { turn: 3 }),
       at(40, 'turn/start', { turn: 3 }),
       at(41, 'step/start', { turn: 3, step: 1 }),
       at(41, 'step/start', { turn: 3, step: 1 }),
@@ -365,7 +372,7 @@ describe('Trajectory conversation Definitions', () => {
     const namedToolPacked = snapshot(assembler(namedToolInputs))
     const namedToolPacked = snapshot(assembler(namedToolInputs))
     expect(namedToolPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({
     expect(namedToolPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({
       blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }],
       blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }],
-      timing: { firstTokenTime: null },
+      timing: { firstTokenTime: 4_000 },
     })
     })
   })
   })
 
 

+ 26 - 0
packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts

@@ -0,0 +1,26 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, expect, it } from 'vitest'
+import { createTrajectoryStringWrappingStore } from '../src/client/string-wrapping-store.ts'
+
+const key = 'dsh.trajectory.jsonStringWrapping'
+let original: string | null
+
+beforeEach(() => {
+  original = localStorage.getItem(key)
+  localStorage.removeItem(key)
+})
+
+afterEach(() => {
+  if (original === null) localStorage.removeItem(key)
+  else localStorage.setItem(key, original)
+})
+
+it('restores the last wrapping choice when the view is recreated', () => {
+  const first = createTrajectoryStringWrappingStore()
+  expect(first.getSnapshot()).toBe(false)
+  first.set(true)
+  const second = createTrajectoryStringWrappingStore()
+  expect(second.getSnapshot()).toBe(true)
+  second.set(false)
+  expect(createTrajectoryStringWrappingStore().getSnapshot()).toBe(false)
+})

+ 39 - 5
packages/client/ui-trajectory/tests/table.client.spec.tsx

@@ -231,7 +231,12 @@ describe('TrajectoryTable', () => {
     expect(panel.querySelector('[data-summary-scroll-region]')).toBeNull()
     expect(panel.querySelector('[data-summary-scroll-region]')).toBeNull()
   })
   })
 
 
-  it('keeps long thinking collapsed until the user asks to render it', () => {
+  it.each([
+    { outputDetail: undefined, toolCall: false },
+    { outputDetail: 'Visible answer', toolCall: false },
+    { outputDetail: undefined, toolCall: true },
+    { outputDetail: 'Visible answer', toolCall: true },
+  ])('opens thinking with output=$outputDetail and toolCall=$toolCall', ({ outputDetail, toolCall }) => {
     const thinking = 'private chain '.repeat(1_000)
     const thinking = 'private chain '.repeat(1_000)
     const turns: readonly TrajectoryTurnModel[] = [{
     const turns: readonly TrajectoryTurnModel[] = [{
       turn: 1,
       turn: 1,
@@ -242,6 +247,10 @@ describe('TrajectoryTable', () => {
           kind: 'message',
           kind: 'message',
           text: 'private chain…',
           text: 'private chain…',
           thinkingDetail: thinking,
           thinkingDetail: thinking,
+          ...(outputDetail === undefined ? {} : { outputDetail }),
+          ...(toolCall ? { sourceBlocks: [{
+            type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read',
+          }] } : {}),
           timeSeconds: 1,
           timeSeconds: 1,
         }],
         }],
       }],
       }],
@@ -249,14 +258,39 @@ describe('TrajectoryTable', () => {
     render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
     render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
 
 
     fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
     fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
+    fireEvent.click(screen.getByRole('tab', { name: 'Preview' }))
     const toggle = screen.getByRole('button', { name: 'Thinking' })
     const toggle = screen.getByRole('button', { name: 'Thinking' })
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
+    expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
+
+    fireEvent.click(toggle)
     expect(toggle.getAttribute('aria-expanded')).toBe('false')
     expect(toggle.getAttribute('aria-expanded')).toBe('false')
     expect(screen.queryByText(thinking)).toBeNull()
     expect(screen.queryByText(thinking)).toBeNull()
+    fireEvent.click(screen.getByRole('tab', { name: 'Summary' }))
+    expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(screen.getByRole('tab', { name: 'Preview' }))
+    fireEvent.click(screen.getByRole('button', { name: 'Thinking' }))
+    expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('true')
+  })
 
 
-    fireEvent.click(toggle)
-    expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle)
-    expect(toggle.getAttribute('aria-expanded')).toBe('true')
-    expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
+  it('opens thinking on another record after collapsing the selected record', () => {
+    const turns: readonly TrajectoryTurnModel[] = [{
+      turn: 1,
+      groups: [{
+        title: 'Step 1',
+        cells: [1, 2].map(index => ({
+          index, kind: 'message', text: `Answer ${index}`, outputDetail: `Answer ${index}`,
+          thinkingDetail: `Reasoning ${index}`, timeSeconds: 1,
+        })),
+      }],
+    }]
+    render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
+    fireEvent.click(screen.getByRole('row', { name: /ASSISTANT, Answer 1/ }))
+    fireEvent.click(screen.getByRole('button', { name: 'Thinking' }))
+    expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(screen.getByRole('row', { name: /ASSISTANT, Answer 2/ }))
+    expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByText('Reasoning 2')).toBeTruthy()
   })
   })
 
 
   it('keeps raw HTML tags in a Markdown-derived context preview', () => {
   it('keeps raw HTML tags in a Markdown-derived context preview', () => {

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

@@ -538,7 +538,7 @@ describe('tab switching in ConversationRoot', () => {
 
 
     fireEvent.keyDown(screen.getByRole('row', { name: /工具/ }), { key: 'Enter' })
     fireEvent.keyDown(screen.getByRole('row', { name: /工具/ }), { key: 'Enter' })
     expect(screen.getByRole('complementary', { name: '事件详情' })).toBeTruthy()
     expect(screen.getByRole('complementary', { name: '事件详情' })).toBeTruthy()
-    expect(screen.getByText('第 1 轮 · 步骤 1')).toBeTruthy()
+    expect(screen.getByText('第 1 轮 · 第 1 步')).toBeTruthy()
     expect(screen.getByText('已完成')).toBeTruthy()
     expect(screen.getByText('已完成')).toBeTruthy()
     expect(screen.getByRole('tab', { name: '结果' })).toBeTruthy()
     expect(screen.getByRole('tab', { name: '结果' })).toBeTruthy()
 
 

+ 4 - 2
snapshots/web/auto-review-denial/ui.expected.md

@@ -121,9 +121,11 @@
         - 'button "Request #2"'
         - 'button "Request #2"'
         - text: ASSISTANT
         - text: ASSISTANT
       - cell "(tool call only)"
       - cell "(tool call only)"
-    - 'row "TOOL, run_code {\"code\":\"try { await tools.bash({command: \"echo hidden-input-ptc\"}) } catch (error) { return \"denial caught\" }\",\"description\":\"Catch an Auto-denied inner call\"}"':
+    - row "TOOL, run_code Catch an Auto-denied inner call":
       - cell "TOOL"
       - cell "TOOL"
-      - 'cell "run_code{\"code\":\"try { await tools.bash({command: \"echo hidden-input-ptc\"}) } catch (error) { return \"denial caught\" }\",\"description\":\"Catch an Auto-denied inner call\"} → denial caught"'
+      - cell "run_codeCatch an Auto-denied inner call → denial caught":
+        - img
+        - text: run_codeCatch an Auto-denied inner call → denial caught
     - 'row "SUBTOOL, bash {\"command\":\"echo hidden-input-ptc\"}" [selected]':
     - 'row "SUBTOOL, bash {\"command\":\"echo hidden-input-ptc\"}" [selected]':
       - cell "SUBTOOL"
       - cell "SUBTOOL"
       - 'cell "bash{\"command\":\"echo hidden-input-ptc\"} → AUTO_REVIEW_DENIED"'
       - 'cell "bash{\"command\":\"echo hidden-input-ptc\"} → AUTO_REVIEW_DENIED"'

+ 12 - 0
snapshots/web/navigation-panes/timing.expected.md

@@ -0,0 +1,12 @@
+- tabpanel "Timing":
+  - term: Started
+  - definition:
+    - button "2026-01-01 {{clock}}"
+  - term: Total duration
+  - definition: {{duration}}
+  - term: TTFT
+  - definition: {{duration}}
+  - term: Generation
+  - definition: {{duration}}
+  - term: Throughput
+  - definition: {{throughput}} tok/s

+ 7 - 0
snapshots/web/ptc-round/code.expected.md

@@ -0,0 +1,7 @@
+- tabpanel "Code":
+  - text: Code typescript
+  - button "Wrap lines" [pressed]
+  - button "Original JSON"
+  - button "Copy code":
+    - img
+  - code: "const bashResult = await tools.bash({ command: \"echo CODE_ROUND_OK\", description: \"Echo CODE_ROUND_OK\" }); let readError = null; try { await tools.read({ file_path: \"missing.txt\" }); } catch (e) { readError = { toolName: e.toolName, message: e.message }; } return { bash: bashResult.stdout.text.trim(), readError };"

+ 22 - 0
snapshots/web/ptc-round/trajectory.expected.md

@@ -0,0 +1,22 @@
+- heading "Code typescript Wrap lines Copy code" [level=3]:
+  - button "Code":
+    - text: Code
+    - img
+  - text: typescript
+  - button "Wrap lines"
+  - button "Copy code":
+    - img
+- code: "const bashResult = await tools.bash({ command: \"echo CODE_ROUND_OK\", description: \"Echo CODE_ROUND_OK\" }); let readError = null; try { await tools.read({ file_path: \"missing.txt\" }); } catch (e) { readError = { toolName: e.toolName, message: e.message }; } return { bash: bashResult.stdout.text.trim(), readError };"
+- heading "Output Copy output" [level=3]:
+  - button "Output":
+    - text: Output
+    - img
+  - button "Copy output":
+    - img
+- text: "{"
+- tree "Result JSON":
+  - treeitem "bash:\"CODE_ROUND_OK\","
+  - 'treeitem "Expand readError:{toolName: \"read\", message: \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"}"':
+    - button "Expand"
+    - text: "readError:{toolName: \"read\", message: \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"}"
+- text: "}"