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

Merge pull request #977 from deepseek-harness/xtr/trajectory-timeline-click-focus

feat(ui): refine trajectory navigation and streaming performance
Tianyi Cui 1 месяц назад
Родитель
Сommit
805ba9733e
24 измененных файлов с 1082 добавлено и 128 удалено
  1. 6 0
      .agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml
  2. 25 0
      .agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md
  3. 25 0
      .agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md
  4. 49 2
      apps/web/tests/details-session-lifecycle.e2e.ts
  5. 7 0
      apps/web/tests/snapshots/details-session-lifecycle/handles.expected.md
  6. 2 2
      apps/web/tests/snapshots/navigation-panes/trajectory.expected.md
  7. 102 13
      packages/client/runtime/src/client/session-history/source.ts
  8. 12 2
      packages/client/runtime/src/client/sessions/partial.ts
  9. 6 0
      packages/client/runtime/tests/partial.spec.ts
  10. 70 1
      packages/client/runtime/tests/session-history-source.spec.ts
  11. 2 2
      packages/client/ui-layout/README.i18n.yaml
  12. 1 1
      packages/client/ui-layout/README.md
  13. 1 1
      packages/client/ui-layout/README.zh.md
  14. 9 11
      packages/client/ui-layout/src/client/AppFrame.module.css
  15. 94 0
      packages/client/ui-trajectory/src/client/TrajectoryTable.module.css
  16. 162 37
      packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
  17. 31 2
      packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css
  18. 170 43
      packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx
  19. 18 1
      packages/client/ui-trajectory/src/client/TrajectoryView.tsx
  20. 29 6
      packages/client/ui-trajectory/src/client/layout.ts
  21. 3 0
      packages/client/ui-trajectory/src/client/timeline.ts
  22. 19 0
      packages/client/ui-trajectory/tests/layout.spec.tsx
  23. 71 1
      packages/client/ui-trajectory/tests/table.spec.tsx
  24. 168 3
      packages/client/ui-trajectory/tests/views.spec.tsx

+ 6 - 0
.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.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/simplification/2026-07-30-sidebar-resize-without-visible-pill.md
+2026-07-30-sidebar-resize-without-visible-pill.md: cc41898990fa23ff2937140186a8324217911d2e
+2026-07-30-sidebar-resize-without-visible-pill.zh.md: 9f1f521df2848b15f5015719bfa6f0e0e9b7be0c

+ 25 - 0
.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md

@@ -0,0 +1,25 @@
+# Agent Note: Sidebar resize without a visible pill
+
+Status: implemented
+
+English | [中文](2026-07-30-sidebar-resize-without-visible-pill.zh.md)
+
+## Problem
+
+The AppFrame exposed identical floating pills on both column borders. The left pill added unnecessary visual weight beside primary navigation, but the sidebar's resize interaction remains useful.
+
+## Decision
+
+AppFrame keeps the sidebar's 8px resize hit strip, `col-resize` cursor, pointer capture, animation-frame throttling, and width updates, but does not generate the sidebar handle's pill pseudo-element. The details boundary retains both its hit strip and floating pill.
+
+The layout component test continues to pin sidebar dragging and both handles' collapse lifecycle. A keyless browser scenario reads the generated pseudo-elements from the shipped composition and drags the invisible sidebar boundary to prove the interaction remains live.
+
+## Alternatives considered
+
+**Remove the sidebar drag interaction with the pill.** Rejected because the requested change is visual; removing a working geometry control would unnecessarily narrow the interaction.
+
+**Keep the pill but reduce its emphasis.** A smaller or lower-contrast pill still leaves an unwanted object on the sidebar boundary.
+
+## Consequences
+
+The sidebar boundary is visually quiet while pointer resizing remains available from the boundary and retains the resize cursor. Unlike the details control, that interaction has no visible pill.

+ 25 - 0
.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 侧边栏缩放不显示胶囊
+
+Status: implemented
+
+[English](2026-07-30-sidebar-resize-without-visible-pill.md) | 中文
+
+## 问题
+
+AppFrame 在两个栏位边界都显示相同的浮动胶囊。左侧胶囊在主导航旁增加了不必要的视觉负担,但侧边栏的缩放交互仍有用。
+
+## 决策
+
+AppFrame 保留侧边栏宽 8px 的缩放命中条带、`col-resize` 光标、指针捕获、动画帧节流和宽度更新,但不再生成侧边栏手柄的胶囊形伪元素。详情栏边界同时保留命中条带和浮动胶囊。
+
+布局组件测试继续固定侧边栏拖动行为,以及两个手柄随面板折叠时的生命周期。一个无密钥浏览器场景读取实际交付组合所生成的伪元素,并拖动不可见的侧边栏边界,证明该交互仍然有效。
+
+## 曾考虑的替代方案
+
+**随胶囊一并移除侧边栏拖动交互。** 不予采纳,因为本次要求只改视觉表现;移除正常工作的几何控制会不必要地缩减交互方式。
+
+**保留胶囊,但降低其视觉强调。** 更小或对比度更低的胶囊仍会在侧边栏边界留下一个不需要的物体。
+
+## 后果
+
+侧边栏边界在视觉上保持简洁,同时仍可在边界处通过指针调整宽度,并保留缩放光标。与详情栏控件不同,该交互没有可见胶囊。

+ 49 - 2
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -3,15 +3,19 @@
 // unselected states, and closes it only when a different Session takes ownership.
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
-  acknowledgeReloadConnectionLoss, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole,
-  webSnapshotMode, type WebScaffold,
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, compareOrRefreshGolden,
+  fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
+  type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/details-session-lifecycle', import.meta.url))
+const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md')
 const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url))
 const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
 const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
@@ -25,11 +29,41 @@ async function detailsTrack(page: Page): Promise<number> {
   })
 }
 
+/** First AppFrame grid track in CSS pixels. */
+async function sidebarTrack(page: Page): Promise<number> {
+  return await appFrame(page).evaluate((element) => {
+    const tracks = getComputedStyle(element).gridTemplateColumns.split(' ')
+    return Number.parseFloat(tracks[0] ?? 'NaN')
+  })
+}
+
 /** AppFrame is the only product element with an inline grid track template. */
 function appFrame(page: Page) {
   return page.locator('[style*="grid-template-columns"]').first()
 }
 
+/** Render the two boundary affordances without platform-dependent coordinates. */
+async function handleSnapshot(page: Page): Promise<string> {
+  const handles = await page.locator('[class*="handle"]').evaluateAll(elements =>
+    elements.map(element => ({
+      side: element.getAttribute('data-side'),
+      cursor: getComputedStyle(element).cursor,
+      pillGenerated: getComputedStyle(element, '::after').content !== 'none',
+    })))
+  return [
+    '# AppFrame drag handles',
+    '',
+    ...handles.flatMap(handle => [
+      `## ${handle.side}`,
+      '',
+      '- hit strip present: true',
+      `- cursor: ${handle.cursor}`,
+      `- pill generated: ${String(handle.pillGenerated)}`,
+      '',
+    ]),
+  ].join('\n').trimEnd()
+}
+
 describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -65,6 +99,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
 
     await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
     expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
+    await compareOrRefreshGolden(HANDLES_EXPECTED, await handleSnapshot(page), MODE)
+
+    const sidebarBefore = await sidebarTrack(page)
+    const sidebarHandle = page.locator('[data-side="sidebar"]')
+    const sidebarBox = await sidebarHandle.boundingBox()
+    expect(sidebarBox).not.toBeNull()
+    const dragStartX = sidebarBox!.x + sidebarBox!.width / 2
+    await page.mouse.move(dragStartX, sidebarBox!.y + 200)
+    await page.mouse.down()
+    await page.mouse.move(dragStartX + 70, sidebarBox!.y + 200, { steps: 6 })
+    await page.mouse.up()
+    await expect.poll(() => sidebarTrack(page), { timeout: 5_000 }).toBe(sidebarBefore + 70)
 
     const warningStart = tripwire.warnings.length
     await page.reload({ waitUntil: 'load' })
@@ -101,5 +147,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md'])
   }, 90_000)
 })

+ 7 - 0
apps/web/tests/snapshots/details-session-lifecycle/handles.expected.md

@@ -0,0 +1,7 @@
+# AppFrame drag handles
+
+## sidebar
+
+- hit strip present: true
+- cursor: col-resize
+- pill generated: false

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

@@ -11,7 +11,7 @@
       - cell "SYSTEM"
       - cell "Initial System Prompt"
     - 'row "USER, NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."':
-      - cell "Turn 1 USER"
+      - cell "Turn 1 USER": USER
       - 'cell "NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."'
     - 'row "Request 1, ASSISTANT, The user wants me to follow a specific navigation scenario. Let me: Run bash to print \"NAVIGATION_OK\" Read nav-a.md and nav-b.md in two read calls in ONE message Reply with \"FIRST_DONE\" Let me start with the bash command and the reads."':
       - 'cell "Request #1 ASSISTANT"':
@@ -33,7 +33,7 @@
         - text: ASSISTANT
       - cell "FIRST_DONE"
     - 'row "USER, Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."':
-      - cell "Turn 2 USER"
+      - cell "Turn 2 USER": USER
       - 'cell "Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."'
     - row "Request 3, ASSISTANT, Navigation Summary alpha nav beta nav echo WATERFALL":
       - 'cell "Request #3 ASSISTANT"':

+ 102 - 13
packages/client/runtime/src/client/session-history/source.ts

@@ -1,12 +1,14 @@
 import type {
   HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
 } from '@deepseek-ai/dsh-client-connection/client'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type {
   SessionHistoryFace, SessionHistorySnapshot,
 } from '../contract/session-history.ts'
 import { createHistoryInspection } from '../sessions/history.ts'
 import { Notifier } from '../sessions/notifier.ts'
+import { PartialAccumulator } from '../sessions/partial.ts'
 
 const HISTORY_PAGE_MESSAGES = 50
 
@@ -33,6 +35,9 @@ export class SessionHistorySource implements SessionHistoryFace {
     entries: readonly HistoryEntry[]
     value: SessionHistorySnapshot['inspection']
   } | null = null
+  private streamPublishToken: object | null = null
+  private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
+  private streamPartial: PartialAccumulator | null = null
   private snapshotCache: SessionHistorySnapshot
   private readonly notifier = new Notifier(() => {
     this.snapshotCache = this.buildSnapshot()
@@ -125,7 +130,7 @@ export class SessionHistorySource implements SessionHistoryFace {
     if (this.state !== 'cold') {
       this.state = 'cold'
       this.error = null
-      this.notifier.markDirty()
+      this.publishDirtyNow()
     }
   }
 
@@ -143,7 +148,7 @@ export class SessionHistorySource implements SessionHistoryFace {
     this.hasMore = false
     this.state = 'cold'
     this.error = null
-    this.notifier.markDirty()
+    this.publishDirtyNow()
     void this.loadForConsumers()
   }
 
@@ -155,6 +160,9 @@ export class SessionHistorySource implements SessionHistoryFace {
     this.openPromise = null
     this.olderPromise = null
     this.liveBuffer = []
+    this.streamPublishToken = null
+    this.streamBaseInspection = null
+    this.streamPartial = null
   }
 
   private open(): Promise<void> {
@@ -188,7 +196,7 @@ export class SessionHistorySource implements SessionHistoryFace {
   private async doOpen(generation: number): Promise<void> {
     this.state = 'loading'
     this.error = null
-    this.notifier.markDirty()
+    this.publishDirtyNow()
     try {
       let { result } = await this.api.sessions.history({
         sessionId: this.sessionId,
@@ -222,7 +230,7 @@ export class SessionHistorySource implements SessionHistoryFace {
       /* v8 ignore next -- transportError always returns the error branch. */
       this.error = folded.ok ? null : folded.error
     } finally {
-      if (generation === this.generation) this.notifier.markDirty()
+      if (generation === this.generation) this.publishDirtyNow()
     }
   }
 
@@ -261,7 +269,7 @@ export class SessionHistorySource implements SessionHistoryFace {
     const settled = operation.finally(() => {
       if (this.olderPromise !== settled) return
       this.olderPromise = null
-      this.notifier.markDirty()
+      this.publishDirtyNow()
     })
     this.olderPromise = settled
     return settled
@@ -286,7 +294,7 @@ export class SessionHistorySource implements SessionHistoryFace {
     const buffered = this.liveBuffer
     this.liveBuffer = []
     for (const entry of buffered) this.appendLive(entry)
-    this.notifier.markDirty()
+    this.publishDirtyNow()
   }
 
   private acceptLive(entry: HistoryEntry): void {
@@ -301,8 +309,16 @@ export class SessionHistorySource implements SessionHistoryFace {
       void this.repairGap()
       return
     }
+    if (
+      entry.event.type === 'assistant/chunk'
+      && entry.event.data.chunk.type !== 'usage'
+    ) {
+      if (!this.appendIncrementalChunk(entry, entry.event)) return
+      this.publishStreamDirty()
+      return
+    }
     this.appendLive(entry)
-    this.notifier.markDirty()
+    this.publishDirtyNow()
   }
 
   private appendLive(entry: HistoryEntry): void {
@@ -311,6 +327,66 @@ export class SessionHistorySource implements SessionHistoryFace {
     this.entries = [...this.entries, entry]
   }
 
+  /** Append a chunk against the cached finalized projection; false means no visible publish. */
+  private appendIncrementalChunk(
+    entry: HistoryEntry,
+    event: SessionEvent<'assistant/chunk'>,
+  ): boolean {
+    const { turn, step, chunk } = event.data
+    if (!isVisibleAssistantChunk(chunk.type)) {
+      const inspection = this.currentInspection()
+      this.appendLive(entry)
+      this.inspectionCache = { entries: this.entries, value: inspection }
+      return false
+    }
+    const base = this.streamBaseInspection ?? this.currentInspection()
+    this.streamBaseInspection = base
+    if (
+      this.streamPartial === null
+      || this.streamPartial.turn !== turn
+      || this.streamPartial.step !== step
+    ) {
+      const current = base.partial
+      this.streamPartial = new PartialAccumulator(
+        turn,
+        step,
+        current?.turn === turn && current.step === step ? current.blocks : [],
+      )
+    }
+    this.streamPartial.push(chunk)
+    this.appendLive(entry)
+    this.inspectionCache = {
+      entries: this.entries,
+      value: { ...base, partial: this.streamPartial.toPartial() },
+    }
+    return true
+  }
+
+  /** Coalesce token-stream projection and rendering work to one publish per browser frame. */
+  private publishStreamDirty(): void {
+    if (this.streamPublishToken !== null) return
+    const token = {}
+    this.streamPublishToken = token
+    const publish = () => {
+      if (this.streamPublishToken !== token) return
+      this.streamPublishToken = null
+      this.notifier.markDirty()
+    }
+    if (typeof globalThis.requestAnimationFrame === 'function') {
+      globalThis.requestAnimationFrame(publish)
+    } else {
+      queueMicrotask(publish)
+    }
+  }
+
+  /** Publish structural changes immediately and invalidate an older scheduled stream publish. */
+  private publishDirtyNow(): void {
+    this.streamPublishToken = null
+    this.streamBaseInspection = null
+    this.streamPartial = null
+    this.notifier.markDirty()
+  }
+
   private async repairGap(): Promise<void> {
     if (this.stitching) return
     this.stitching = true
@@ -335,6 +411,16 @@ export class SessionHistorySource implements SessionHistoryFace {
   }
 
   private buildSnapshot(): SessionHistorySnapshot {
+    return {
+      state: this.state,
+      error: this.error,
+      hasMore: this.hasMore,
+      inspection: this.currentInspection(),
+    }
+  }
+
+  /** Inspection pinned to the source's current immutable entry array. */
+  private currentInspection(): SessionHistorySnapshot['inspection'] {
     if (this.inspectionCache?.entries !== this.entries) {
       const entries = this.entries
       this.inspectionCache = {
@@ -342,11 +428,14 @@ export class SessionHistorySource implements SessionHistoryFace {
         value: createHistoryInspection(() => entries),
       }
     }
-    return {
-      state: this.state,
-      error: this.error,
-      hasMore: this.hasMore,
-      inspection: this.inspectionCache.value,
-    }
+    return this.inspectionCache.value
   }
 }
+
+function isVisibleAssistantChunk(type: string): boolean {
+  return type === 'block-start'
+    || type === 'text-delta'
+    || type === 'reasoning-delta'
+    || type === 'tool-call-delta'
+    || type === 'block-end'
+}

+ 12 - 2
packages/client/runtime/src/client/sessions/partial.ts

@@ -13,8 +13,18 @@ export class PartialAccumulator {
   private changed = true
   private snapshot: PartialAssistant
 
-  constructor(readonly turn: number, readonly step: number) {
-    this.snapshot = { turn, step, blocks: [] }
+  /**
+   * @param turn - Owning agent turn.
+   * @param step - Owning model step.
+   * @param initialBlocks - Materialized prefix when accumulation begins after history replay.
+   */
+  constructor(
+    readonly turn: number,
+    readonly step: number,
+    initialBlocks: readonly AssistantBlock[] = [],
+  ) {
+    this.blocks = [...initialBlocks]
+    this.snapshot = { turn, step, blocks: initialBlocks }
   }
 
   /**

+ 6 - 0
packages/client/runtime/tests/partial.spec.ts

@@ -41,6 +41,12 @@ describe('PartialAccumulator', () => {
     expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
   })
 
+  it('continues from a materialized history prefix', () => {
+    const acc = new PartialAccumulator(1, 0, [{ kind: 'text', text: '已有' }])
+    acc.push(chunk({ type: 'text-delta', index: 0, text: '增量' }))
+    expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '已有增量' }])
+  })
+
   it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
     const acc = new PartialAccumulator(1, 0)
     acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))

+ 70 - 1
packages/client/runtime/tests/session-history-source.spec.ts

@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
 import { SessionHistorySource } from '../src/client/session-history/source.ts'
@@ -7,6 +7,10 @@ import { entries, ev, plainTurn } from './event-script.ts'
 
 const SID = 'history-s1' as SessionId
 
+afterEach(() => {
+  vi.unstubAllGlobals()
+})
+
 function histResponse(events: SessionEvent[], hasMore = false) {
   return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
 }
@@ -52,6 +56,71 @@ describe('SessionHistorySource', () => {
       .toEqual([1, 3, 6])
   })
 
+  it('publishes multiple assistant chunks once per browser frame', async () => {
+    const api = new FakeApiClient()
+    api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
+    const source = new SessionHistorySource(SID, api)
+    await source.loadAll()
+    const frames: FrameRequestCallback[] = []
+    vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+      frames.push(callback)
+      return frames.length
+    })
+    let notifications = 0
+    const unsubscribe = source.subscribe(() => { notifications++ })
+    const before = source.getSnapshot().inspection
+    const finalizedNodes = before.eventNodes
+    const requests = before.requests
+    const contexts = before.contexts
+
+    for (const event of [
+      ev.chunkStart(6, 1),
+      ev.chunkText(7, 1, 'stream '),
+      ev.chunkText(8, 1, 'content'),
+    ]) {
+      source.handleMuxFrame({
+        type: 'session/event',
+        sessionId: SID,
+        event,
+      })
+    }
+
+    expect(frames).toHaveLength(1)
+    expect(notifications).toBe(0)
+    frames[0]?.(0)
+    await Promise.resolve()
+
+    expect(notifications).toBe(1)
+    const streamed = source.getSnapshot().inspection
+    expect(streamed.eventNodes).toBe(finalizedNodes)
+    expect(streamed.requests).toBe(requests)
+    expect(streamed.contexts).toBe(contexts)
+    expect(streamed.partial?.blocks).toEqual([
+      { kind: 'text', text: 'stream content' },
+    ])
+
+    source.handleMuxFrame({
+      type: 'session/event',
+      sessionId: SID,
+      event: ev.chunkText(9, 1, ' then final'),
+    })
+    source.handleMuxFrame({
+      type: 'session/event',
+      sessionId: SID,
+      event: ev.assistant(10, 1, 'stream content then final'),
+    })
+    await Promise.resolve()
+
+    expect(notifications).toBe(2)
+    const finalized = source.getSnapshot().inspection
+    expect(finalized.eventNodes).not.toBe(finalizedNodes)
+    expect(finalized.partial).toBeNull()
+    frames[1]?.(0)
+    await Promise.resolve()
+    expect(notifications).toBe(2)
+    unsubscribe()
+  })
+
   it('stops loading when an older page fails to advance', async () => {
     const api = new FakeApiClient()
     api.onHistory = payload => payload.beforeSeq === undefined

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

@@ -2,5 +2,5 @@
 # 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 packages/client/ui-layout/README.md
-README.md: 0e92958c9b088071ab58f7e87e8af68f6c2df68d
-README.zh.md: 0fb3b1cd85bbf6e070a2a1dd01b25981e5990df0
+README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba
+README.zh.md: 3681b4517670eb92d8f32be2ac62d5852ac745a3

+ 1 - 1
packages/client/ui-layout/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
+Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
 
 AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
 

+ 1 - 1
packages/client/ui-layout/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
+外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
 
 AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。
 

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

@@ -49,9 +49,8 @@
 }
 
 /* Drag handles are frame children (columns clip overflow): an 8px hit strip
-   centered on the column border via inline left, above column content. The
-   visible pill (12x32 r10, riding the border at vertical center) is the figma
-   Handle component; the hit strip stays wider than the pill. */
+   centered on the column border via inline left, above column content. Details
+   adds a visible 12x32 pill at vertical center; sidebar keeps only the hit strip. */
 .handle {
   position: absolute;
   top: 0;
@@ -76,7 +75,7 @@
   }
 }
 
-.handle::after {
+.handle[data-side='details']::after {
   content: '';
   position: absolute;
   top: 50%;
@@ -88,23 +87,22 @@
   box-sizing: border-box;
   background: var(--dsw-alias-button-floating-fill);
   border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
-  /* Hover affordance: the pill hides until the pointer is over the owning
-     column (data-side pairs handle and column), the strip itself, or a drag. */
+  /* Hover affordance: the details pill hides until the pointer is over its
+     column, the strip itself, or a drag. */
   opacity: 0;
   transition:
     opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
     background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
 }
 
-.sidebarCol:hover ~ .handle[data-side='sidebar']::after,
 .detailsCol:hover ~ .handle[data-side='details']::after,
-.handle:hover::after,
-.handle[data-dragging='true']::after {
+.handle[data-side='details']:hover::after,
+.handle[data-side='details'][data-dragging='true']::after {
   opacity: 1;
 }
 
-.handle:hover::after,
-.handle[data-dragging='true']::after {
+.handle[data-side='details']:hover::after,
+.handle[data-side='details'][data-dragging='true']::after {
   background: var(--dsw-alias-button-floating-hover);
   border-color: var(--dsw-alias-border-l3);
 }

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

@@ -16,6 +16,7 @@
   flex: 1;
   min-width: 0;
   overflow: auto;
+  container: trajectory-table / inline-size;
 }
 
 .table {
@@ -235,6 +236,18 @@
   width: 3px;
 }
 
+.table tbody tr[data-error='true'] .turnRail {
+  background: color-mix(
+    in srgb,
+    var(--dsw-alias-state-error-primary) 22%,
+    var(--dsw-alias-bg-layer-1)
+  );
+}
+
+.table tbody tr[data-error='true'] .selectionRail {
+  background: var(--dsw-alias-state-error-primary);
+}
+
 .table tbody tr[data-turn-start='true'] td {
   position: relative;
   overflow: visible;
@@ -279,6 +292,10 @@
   white-space: nowrap;
 }
 
+.turnLabelCompact {
+  display: none;
+}
+
 .turnLabelActive {
   color: color-mix(
     in srgb,
@@ -325,11 +342,66 @@
   user-select: none;
 }
 
+.kindTagIcon {
+  display: none;
+  align-items: center;
+  justify-content: center;
+  width: 13px;
+  height: 13px;
+}
+
+.kindTagLabel {
+  display: inline;
+}
+
 .table .kindSlot .message {
   justify-content: center;
   width: 100%;
 }
 
+@container trajectory-table (max-width: 620px) {
+  .eventColumn {
+    width: 50px;
+  }
+
+  .event {
+    padding-right: 3px !important;
+    padding-left: 28px !important;
+  }
+
+  .requestBoundaryControl {
+    left: 6px;
+  }
+
+  .kindSlot {
+    width: 19px;
+  }
+
+  .kindTag,
+  .table .kindSlot .message {
+    justify-content: center;
+    width: 19px;
+    padding-right: 0;
+    padding-left: 0;
+  }
+
+  .kindTagIcon {
+    display: inline-flex;
+  }
+
+  .kindTagLabel {
+    display: none;
+  }
+
+  .turnLabelFull {
+    display: none;
+  }
+
+  .turnLabelCompact {
+    display: inline;
+  }
+}
+
 .user {
   color: var(--dsw-alias-state-business-primary);
   background: var(--dsw-alias-state-business-tertiary);
@@ -576,6 +648,28 @@
   color: var(--dsw-alias-state-error-primary);
 }
 
+.overview dd.error {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.details .errorPayload {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.details .errorPayload .resultBlockText {
+  color: inherit;
+}
+
+.details .jsonPayload.errorPayload,
+.details .jsonPreview.errorPayload {
+  --json-tree-property: var(--dsw-alias-state-error-primary);
+  --json-tree-string: var(--dsw-alias-state-error-primary);
+  --json-tree-number: var(--dsw-alias-state-error-primary);
+  --json-tree-keyword: var(--dsw-alias-state-error-primary);
+  --json-tree-punctuation: var(--dsw-alias-state-error-primary);
+  --json-tree-icon: var(--dsw-alias-state-error-primary);
+}
+
 .details {
   position: relative;
   display: flex;

+ 162 - 37
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -1,9 +1,15 @@
 /** Turn-aware trajectory event ledger with a local record inspector. */
 
-import { useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import type { CSSProperties, ReactNode } from 'react'
 import {
-  extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
+  IconChevronRightOutline14,
+  IconSettingsOutline16,
+  IconSparkle16,
+  IconUserOutline16,
+  JsonTree,
+  MarkdownText,
+  Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { structuredPatch } from 'diff'
 import type {
@@ -13,7 +19,7 @@ import type {
   AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
 } from './trajectory-record.ts'
 import { formatElapsedSeconds } from './trajectory-record.ts'
-import type { TrajectoryTurnModel } from './layout.ts'
+import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
 import css from './TrajectoryTable.module.css'
 
 const KIND_LABEL: Record<TrajectoryCellKind, string> = {
@@ -26,6 +32,77 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
   subtool: 'SUBTOOL',
 }
 
+function ToolWrenchIcon(): ReactNode {
+  return (
+    <svg
+      width="13"
+      height="13"
+      viewBox="0 0 16 16"
+      fill="none"
+      stroke="currentColor"
+      strokeWidth="1.5"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      data-role-icon="wrench"
+      aria-hidden="true"
+    >
+      <path d="M14 3.3a3.8 3.8 0 0 1-4.8 4.8l-5.1 5.1a1.6 1.6 0 1 1-2.3-2.3l5.1-5.1A3.8 3.8 0 0 1 11.7 1l-2.3 2.3 2.3 2.3L14 3.3Z" />
+    </svg>
+  )
+}
+
+function InformationIcon(): ReactNode {
+  return (
+    <svg
+      width="14"
+      height="14"
+      viewBox="0 0 16 16"
+      fill="none"
+      stroke="currentColor"
+      strokeWidth="1.4"
+      strokeLinecap="round"
+      data-role-icon="information"
+      aria-hidden="true"
+    >
+      <circle cx="8" cy="8" r="6.7" />
+      <circle cx="8" cy="5.5" r=".85" fill="currentColor" stroke="none" />
+      <path d="M8 7.75v3.4" strokeWidth="1.8" />
+    </svg>
+  )
+}
+
+function CompactedIcon(): ReactNode {
+  return (
+    <svg
+      width="13"
+      height="13"
+      viewBox="0 0 16 16"
+      fill="none"
+      stroke="currentColor"
+      strokeWidth="1.5"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      data-role-icon="compacted"
+      aria-hidden="true"
+    >
+      <path d="m2.5 2.5 3.75 3.75M3 6.25h3.25V3" />
+      <path d="m13.5 2.5-3.75 3.75M13 6.25H9.75V3" />
+      <path d="m2.5 13.5 3.75-3.75M3 9.75h3.25V13" />
+      <path d="m13.5 13.5-3.75-3.75M13 9.75H9.75V13" />
+    </svg>
+  )
+}
+
+const KIND_ICON: Record<TrajectoryCellKind, ReactNode> = {
+  system: <IconSettingsOutline16 size={13} />,
+  user: <IconUserOutline16 size={13} />,
+  context: <InformationIcon />,
+  compacted: <CompactedIcon />,
+  message: <IconSparkle16 size={13} />,
+  tool: <ToolWrenchIcon />,
+  subtool: <ToolWrenchIcon />,
+}
+
 interface TableRecord {
   turn: number
   group: string
@@ -225,6 +302,8 @@ export interface TrajectoryTableProps {
   onSelectedIndexChange?: (index: number | null) => void
   /** Report a direct user selection from a ledger row. */
   onRecordSelect?: (index: number) => void
+  /** One externally requested record selection; a new object repeats the request. */
+  recordSelection?: { readonly index: number } | null
   /** Clear selection state owned by the ledger host. */
   onClearSelection?: () => void
   /** Turn ids whose rows after the first are folded into a summary. */
@@ -721,13 +800,13 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
 
 function recordDisplayText(cell: TrajectoryCellProps): string {
   if (isToolCallOnly(cell)) return ''
+  if (cell.text !== '') return cell.text
   const markdown = cell.kind === 'user' || cell.kind === 'context'
     ? cell.inputDetail
     : cell.kind === 'message'
       ? cell.outputDetail ?? cell.thinkingDetail
       : undefined
-  if (!markdown) return cell.text
-  return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim()
+  return markdown === undefined ? '' : trajectoryPreviewText(markdown)
 }
 
 function toolCallTextParts(
@@ -1043,13 +1122,20 @@ function SystemPromptDiff({
 
 function ToolOutputBlocks({
   blocks,
+  error,
   preview,
 }: {
   blocks: readonly TrajectorySourceBlock[]
+  error: boolean
   preview: boolean
 }) {
   return (
-    <div className={preview ? `${css.resultBlocks} ${css.resultBlocksPreview}` : css.resultBlocks}>
+    <div className={[
+      css.resultBlocks,
+      preview ? css.resultBlocksPreview : undefined,
+      error ? css.errorPayload : undefined,
+    ].filter((value): value is string => value !== undefined).join(' ')}
+    >
       {blocks.map((block, index) => (
         block.imageSrc !== undefined
           ? <PanelImage block={block} preview={preview} key={index} />
@@ -1222,6 +1308,9 @@ function RecordPayload({
     ? 'No payload captured'
     : 'No result captured'
   if (!value) return <p className={css.noPayload}>{missing}</p>
+  const error = direction === 'output' && record.cell.isError === true
+  const payloadClass = preview ? css.jsonPreview : css.jsonPayload
+  const payloadClassName = error ? `${payloadClass} ${css.errorPayload}` : payloadClass
 
   const json = parseJsonContainer(value)
   const singleTextResult = direction === 'output'
@@ -1232,7 +1321,7 @@ function RecordPayload({
       <JsonTree
         data={json}
         label="Result JSON"
-        className={preview ? css.jsonPreview : css.jsonPayload}
+        className={payloadClassName}
       />
     )
   }
@@ -1245,6 +1334,7 @@ function RecordPayload({
     return (
       <ToolOutputBlocks
         blocks={record.cell.outputBlocks}
+        error={error}
         preview={preview}
       />
     )
@@ -1258,7 +1348,11 @@ function RecordPayload({
   )
   if (markdown) {
     return (
-      <div className={preview ? css.markdownPreview : css.markdownPayload}>
+      <div className={[
+        preview ? css.markdownPreview : css.markdownPayload,
+        error ? css.errorPayload : undefined,
+      ].filter((className): className is string => className !== undefined).join(' ')}
+      >
         <MarkdownText text={value} />
       </div>
     )
@@ -1268,7 +1362,7 @@ function RecordPayload({
       <JsonTree
         data={json}
         label={`${direction === 'input' ? 'Payload' : 'Result'} JSON`}
-        className={preview ? css.jsonPreview : css.jsonPayload}
+        className={payloadClassName}
       />
     )
   }
@@ -1276,7 +1370,7 @@ function RecordPayload({
     <pre className={[
       css.payload,
       preview ? css.payloadPreview : undefined,
-      record.cell.isError ? css.error : undefined,
+      error ? css.errorPayload : undefined,
       value === 'No output' ? css.noOutputText : undefined,
     ].filter((value): value is string => value !== undefined).join(' ')}
     >
@@ -1397,6 +1491,7 @@ export function TrajectoryTable({
   searchMatchIndexes = null,
   onSelectedIndexChange,
   onRecordSelect,
+  recordSelection = null,
   onClearSelection,
   collapsedTurns,
   onToggleTurn,
@@ -1406,15 +1501,16 @@ export function TrajectoryTable({
   const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
   const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
   const [activeTab, setActiveTab] = useState<DetailTab>('overview')
-  const [thinkingExpanded, setThinkingExpanded] = useState(true)
+  const [thinkingExpanded, setThinkingExpanded] = useState(false)
   const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
   const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
   const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
+  const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null)
   const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
   useEffect(() => {
     onSelectedIndexChange?.(selectedIndex)
   }, [onSelectedIndexChange, selectedIndex])
-  const allRecords = flattenRecords(turns)
+  const allRecords = useMemo(() => flattenRecords(turns), [turns])
   const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
   const records = searchMatchIndexes === null
     ? collapseAssistantRecords(
@@ -1531,7 +1627,7 @@ export function TrajectoryTable({
     onClearSelection?.()
   }
 
-  const selectRecord = (index: number) => {
+  const selectRecord = useCallback((index: number) => {
     const record = allRecords.find(candidate => candidate.cell.index === index)
     onRecordSelect?.(index)
     setSelectedRequest(null)
@@ -1541,7 +1637,15 @@ export function TrajectoryTable({
     const available = new Set(tabs.map(tab => tab.id))
     const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
     setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
-  }
+  }, [allRecords, onRecordSelect])
+  useEffect(() => {
+    if (
+      recordSelection === null
+      || appliedRecordSelection.current === recordSelection
+    ) return
+    appliedRecordSelection.current = recordSelection
+    selectRecord(recordSelection.index)
+  }, [recordSelection, selectRecord])
 
   const selectRequest = (
     request: SelectedRequest,
@@ -1714,8 +1818,14 @@ export function TrajectoryTable({
                         className={activeTurn === record.turn
                           ? `${css.turnLabel} ${css.turnLabelActive}`
                           : css.turnLabel}
+                        aria-label={`Turn ${record.turn}`}
                       >
-                        Turn {record.turn}
+                        <span className={css.turnLabelFull} aria-hidden="true">
+                          Turn {record.turn}
+                        </span>
+                        <span className={css.turnLabelCompact} aria-hidden="true">
+                          #{record.turn}
+                        </span>
                       </span>
                     )}
                     <div className={css.eventInner}>
@@ -1723,24 +1833,33 @@ export function TrajectoryTable({
                         <span
                           className={css.kindSlot}
                         >
-                          <span className={`${css.kindTag} ${
-                            record.cell.kind === 'system'
-                              ? css.systemNeutral
-                              : record.cell.kind === 'context'
-                                ? css.contextGreen
-                                : record.cell.kind === 'compacted'
-                                  ? css.compacted
-                                  : record.cell.kind === 'tool'
-                                    ? css.toolAmber
-                                    : record.cell.kind === 'message'
-                                      ? css.assistantVioletBright
-                                      : record.cell.kind === 'subtool'
-                                        ? css.subtoolAmber
-                                        : css[record.cell.kind]
-                          }`}
-                          >
-                            {KIND_LABEL[record.cell.kind]}
-                          </span>
+                          <Tooltip label={KIND_LABEL[record.cell.kind]} side="bottom">
+                            <span
+                              className={`${css.kindTag} ${
+                                record.cell.kind === 'system'
+                                  ? css.systemNeutral
+                                  : record.cell.kind === 'context'
+                                    ? css.contextGreen
+                                    : record.cell.kind === 'compacted'
+                                      ? css.compacted
+                                      : record.cell.kind === 'tool'
+                                        ? css.toolAmber
+                                        : record.cell.kind === 'message'
+                                          ? css.assistantVioletBright
+                                          : record.cell.kind === 'subtool'
+                                            ? css.subtoolAmber
+                                            : css[record.cell.kind]
+                              }`}
+                              data-role-kind={record.cell.kind}
+                            >
+                              <span className={css.kindTagIcon} aria-hidden="true">
+                                {KIND_ICON[record.cell.kind]}
+                              </span>
+                              <span className={css.kindTagLabel}>
+                                {KIND_LABEL[record.cell.kind]}
+                              </span>
+                            </span>
+                          </Tooltip>
                         </span>
                       )}
                     </div>
@@ -1973,7 +2092,9 @@ export function TrajectoryTable({
                 <dl className={css.overview}>
                   <div>
                     <dt>Status</dt>
-                    <dd>{statusLabel(selectedRequestState)}</dd>
+                    <dd className={selectedRequestState === 'error' ? css.error : undefined}>
+                      {statusLabel(selectedRequestState)}
+                    </dd>
                   </div>
                   {selectedRequestInfo?.purpose === 'compaction' && (
                     <div>
@@ -2014,7 +2135,7 @@ export function TrajectoryTable({
                   {selectedRequestInfo?.error !== undefined && (
                     <div>
                       <dt>Error</dt>
-                      <dd>{selectedRequestInfo.error}</dd>
+                      <dd className={css.error}>{selectedRequestInfo.error}</dd>
                     </div>
                   )}
                   {selectedRequestInfo?.retry !== undefined && (
@@ -2122,7 +2243,9 @@ export function TrajectoryTable({
                 <dl className={css.overview}>
                   <div>
                     <dt>Status</dt>
-                    <dd>{statusLabel(selectedState)}</dd>
+                    <dd className={selectedState === 'error' ? css.error : undefined}>
+                      {statusLabel(selectedState)}
+                    </dd>
                   </div>
                   <div>
                     <dt>Duration</dt>
@@ -2225,7 +2348,9 @@ export function TrajectoryTable({
                   )}
                   <div>
                     <dt>Status</dt>
-                    <dd>{statusLabel(selectedState)}</dd>
+                    <dd className={selectedState === 'error' ? css.error : undefined}>
+                      {statusLabel(selectedState)}
+                    </dd>
                   </div>
                   {selected.cell.kind === 'message' && (
                     <TokenRows cell={selected.cell} />

+ 31 - 2
packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css

@@ -70,16 +70,29 @@
 .lanes {
   position: absolute;
   z-index: 2;
-  inset: 7px 0;
+  top: 7px;
+  bottom: 7px;
+  left: var(--trajectory-domain-left);
+  width: var(--trajectory-domain-width);
 }
 
 .turnBoundaries {
   position: absolute;
   z-index: 3;
-  inset: 0;
+  top: 0;
+  bottom: 0;
+  left: var(--trajectory-domain-left);
+  width: var(--trajectory-domain-width);
   pointer-events: none;
 }
 
+@media (prefers-reduced-motion: no-preference) {
+  .lanes[data-animate-viewport='true'],
+  .turnBoundaries[data-animate-viewport='true'] {
+    transition: left 180ms ease-out;
+  }
+}
+
 .turnBoundary {
   position: absolute;
   top: 0;
@@ -133,6 +146,10 @@
   );
 }
 
+.span[data-error='true'] {
+  background: var(--dsw-alias-state-error-primary);
+}
+
 .span[data-equal-duration='true'] {
   width: 8px;
   min-width: 8px;
@@ -142,6 +159,18 @@
   opacity: 0.2;
 }
 
+.span[data-hovered='true']:not([data-current='true']) {
+  z-index: 1;
+  opacity: 0.78;
+  box-shadow:
+    0 0 0 1px var(--dsw-alias-bg-layer-2),
+    0 0 0 2px color-mix(
+      in srgb,
+      var(--dsw-alias-state-business-primary) 80%,
+      transparent
+    );
+}
+
 .span[data-current='true'] {
   z-index: 1;
   opacity: 1;

+ 170 - 43
packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx

@@ -15,12 +15,20 @@ import css from './TrajectoryTimeline.module.css'
 
 const MINIMUM_DRAG_PX = 3
 const MINIMUM_ZOOM_OPERATIONS = 4
+const EDGE_PAN_ZONE_FRACTION = 0.08
+const EDGE_PAN_STEP_FRACTION = 0.025
+const MAXIMUM_EDGE_PAN_PX = 32
 
 interface FractionRange {
   start: number
   end: number
 }
 
+interface HoverPoint {
+  fraction: number
+  recordIndex: number | null
+}
+
 /** Props for the fixed full-domain overview above the trajectory ledger. */
 export interface TrajectoryTimelineProps {
   turns: readonly TrajectoryTurnModel[]
@@ -30,6 +38,9 @@ export interface TrajectoryTimelineProps {
   /** Record indexes matching the active ledger search, or null without a query. */
   searchMatchIndexes?: ReadonlySet<number> | null
   onRangeChange: (range: TrajectoryTimeRange | null) => void
+  /** Select a directly clicked timeline block. */
+  onRecordSelect?: (index: number) => void
+  /** Bring the nearest record into view after clicking timeline whitespace. */
   onRecordFocus?: (index: number) => void
 }
 
@@ -41,11 +52,16 @@ function clampFraction(value: number): number {
   return Math.min(1, Math.max(0, value))
 }
 
-function centeredRange(center: number, width: number): FractionRange {
-  const clampedWidth = Math.min(1, Math.max(0, width))
+function centeredRange(
+  center: number,
+  width: number,
+  minimum: number,
+  maximum: number,
+): FractionRange {
+  const clampedWidth = Math.min(maximum - minimum, Math.max(0, width))
   const start = Math.min(
-    Math.max(center - clampedWidth / 2, 0),
-    1 - clampedWidth,
+    Math.max(center - clampedWidth / 2, minimum),
+    maximum - clampedWidth,
   )
   return { start, end: start + clampedWidth }
 }
@@ -79,6 +95,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
   selectedIndex = null,
   searchMatchIndexes = null,
   onRangeChange,
+  onRecordSelect,
   onRecordFocus,
 }: TrajectoryTimelineProps) {
   const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
@@ -94,10 +111,16 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     )),
     [turns],
   )
-  const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
-  const [draft, setDraft] = useState<FractionRange | null>(null)
-  const [hover, setHover] = useState<number | null>(null)
+  const dragRef = useRef<{
+    pointerId: number
+    anchorTime: number
+    anchorClientX: number
+    recordIndex: number | null
+  } | null>(null)
+  const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
+  const [hover, setHover] = useState<HoverPoint | null>(null)
   const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
+  const [animateViewport, setAnimateViewport] = useState(false)
   useEffect(() => {
     if (
       model !== null
@@ -109,11 +132,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
   }, [model, onRangeChange, range])
   useEffect(() => {
     if (model === null) return
+    setAnimateViewport(false)
     setViewport(current =>
       current !== null && (current.end < model.start || current.start > model.end)
         ? null
         : current)
   }, [model])
+  useEffect(() => {
+    if (model === null || selectedIndex === null) return
+    const selectedSpan = model.spans.find(span => span.index === selectedIndex)
+    if (selectedSpan === undefined) return
+    setAnimateViewport(true)
+    setViewport((current) => {
+      if (current === null) return current
+      if (
+        selectedSpan.end > current.start
+        && selectedSpan.start < current.end
+      ) return current
+      const duration = Math.max(1, current.end - current.start)
+      const desiredStart = selectedSpan.end <= current.start
+        ? selectedSpan.start
+        : selectedSpan.end - duration
+      const nextStart = Math.min(
+        Math.max(desiredStart, model.start),
+        Math.max(model.start, model.end - duration),
+      )
+      if (nextStart === current.start) return current
+      return { start: nextStart, end: nextStart + duration }
+    })
+  }, [model, selectedIndex])
   const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
   const viewportDuration = Math.min(
     fullDuration,
@@ -127,16 +174,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     )
   const domainDuration = viewport === null ? fullDuration : viewportDuration
   const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
+  const projectedDomainStyle = model === null
+    ? undefined
+    : {
+      '--trajectory-domain-left':
+        `${-(domainStart - model.start) / domainDuration * 100}%`,
+      '--trajectory-domain-width': `${fullDuration / domainDuration * 100}%`,
+    } as CSSProperties
   const committed = model === null || range === null
     ? null
     : rangeFraction(range, domainStart, domainDuration)
-  const visibleRange = draft ?? committed
-  const activeRange = draft === null
-    ? range
-    : {
-      start: domainStart + draft.start * domainDuration,
-      end: domainStart + draft.end * domainDuration,
-    }
+  const draftFraction = model === null || draft === null
+    ? null
+    : rangeFraction(draft, domainStart, domainDuration)
+  const visibleRange = draftFraction ?? committed
+  const activeRange = draft ?? range
 
   if (model === null) {
     return (
@@ -151,9 +203,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     )
   }
 
-  const minimumSelectionFraction = Math.min(
-    1,
-    fullDuration / domainDuration / model.spans.length,
+  const minimumSelectionDuration = Math.min(
+    domainDuration,
+    fullDuration / model.spans.length,
   )
 
   const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
@@ -161,51 +213,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
   }
 
-  const commit = (fraction: FractionRange) => {
-    onRangeChange({
-      start: domainStart + fraction.start * domainDuration,
-      end: domainStart + fraction.end * domainDuration,
-    })
+  const recordIndexAt = (event: PointerEvent<HTMLDivElement>): number | null => {
+    const target = event.target instanceof HTMLElement ? event.target : null
+    const value = target?.closest<HTMLElement>('[data-timeline-record-index]')
+      ?.dataset.timelineRecordIndex
+    if (value === undefined) return null
+    const index = Number(value)
+    return Number.isFinite(index) ? index : null
+  }
+
+  const commit = (nextRange: TrajectoryTimeRange) => {
+    onRangeChange(nextRange)
   }
 
   const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
     if (event.button !== 0) return
-    const rect = event.currentTarget.getBoundingClientRect()
     const anchor = fractionAt(event)
-    setHover(anchor)
-    dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) }
+    const anchorTime = domainStart + anchor * domainDuration
+    const recordIndex = recordIndexAt(event)
+    setHover({ fraction: anchor, recordIndex })
+    dragRef.current = {
+      pointerId: event.pointerId,
+      anchorTime,
+      anchorClientX: event.clientX,
+      recordIndex,
+    }
     if (typeof event.currentTarget.setPointerCapture === 'function') {
       event.currentTarget.setPointerCapture(event.pointerId)
     }
-    setDraft({ start: anchor, end: anchor })
+    setDraft({ start: anchorTime, end: anchorTime })
   }
 
   const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
     const drag = dragRef.current
+    const rect = event.currentTarget.getBoundingClientRect()
     const fraction = fractionAt(event)
-    setHover(fraction)
+    setHover({ fraction, recordIndex: recordIndexAt(event) })
     if (drag === null || drag.pointerId !== event.pointerId) return
-    setDraft(orderedRange(drag.anchor, fraction))
+    let nextDomainStart = domainStart
+    if (viewport !== null) {
+      const localX = event.clientX - rect.left
+      const edgeWidth = Math.min(
+        MAXIMUM_EDGE_PAN_PX,
+        Math.max(1, rect.width * EDGE_PAN_ZONE_FRACTION),
+      )
+      const direction = localX < edgeWidth
+        ? -1
+        : localX > rect.width - edgeWidth ? 1 : 0
+      if (direction !== 0) {
+        const edgeDistance = direction < 0
+          ? edgeWidth - localX
+          : localX - (rect.width - edgeWidth)
+        const strength = clampFraction(edgeDistance / edgeWidth)
+        const desiredStart = domainStart
+          + direction * domainDuration * EDGE_PAN_STEP_FRACTION
+          * Math.max(0.2, strength)
+        nextDomainStart = Math.min(
+          Math.max(desiredStart, model.start),
+          model.end - domainDuration,
+        )
+        if (nextDomainStart !== domainStart) {
+          setAnimateViewport(false)
+          setViewport({
+            start: nextDomainStart,
+            end: nextDomainStart + domainDuration,
+          })
+        }
+      }
+    }
+    const pointTime = nextDomainStart + fraction * domainDuration
+    setDraft(orderedRange(drag.anchorTime, pointTime))
   }
 
   const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
     const drag = dragRef.current
     if (drag === null || drag.pointerId !== event.pointerId) return
-    const point = fractionAt(event)
-    const selected = orderedRange(drag.anchor, point)
-    setHover(point)
+    const pointFraction = fractionAt(event)
+    const pointTime = domainStart + pointFraction * domainDuration
+    const selected = orderedRange(drag.anchorTime, pointTime)
+    setHover({ fraction: pointFraction, recordIndex: recordIndexAt(event) })
     dragRef.current = null
     setDraft(null)
-    const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX
-    const committedRange = selected.end - selected.start < minimumSelectionFraction
+    const click = Math.abs(event.clientX - drag.anchorClientX) < MINIMUM_DRAG_PX
+    const clickedSpan = click && drag.recordIndex !== null
+      ? model.spans.find(span => span.index === drag.recordIndex)
+      : undefined
+    if (clickedSpan !== undefined) {
+      onRangeChange(null)
+      onRecordSelect?.(clickedSpan.index)
+      return
+    }
+    const committedRange = selected.end - selected.start < minimumSelectionDuration
       ? centeredRange(
         click ? selected.start : (selected.start + selected.end) / 2,
-        minimumSelectionFraction,
+        minimumSelectionDuration,
+        model.start,
+        model.end,
       )
       : selected
     commit(committedRange)
     if (click) {
-      const timelinePoint = domainStart + selected.start * domainDuration
+      const timelinePoint = selected.start
       const nearest = model.spans.reduce((candidate, span) => {
         const candidateDistance = timelinePoint < candidate.start
           ? candidate.start - timelinePoint
@@ -233,6 +341,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
 
   const onWheel = (event: WheelEvent<HTMLDivElement>) => {
     event.preventDefault()
+    setAnimateViewport(false)
     const rect = event.currentTarget.getBoundingClientRect()
     const anchorFraction =
       clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
@@ -278,16 +387,18 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
           onWheel={onWheel}
           onContextMenu={(event) => {
             event.preventDefault()
+            setAnimateViewport(false)
             onRangeChange(null)
             setViewport(null)
           }}
         >
-          {hover !== null && draft === null && (
+          {hover !== null && hover.recordIndex === null && draft === null && (
             <div
               className={css.hoverLine}
+              data-timeline-hover-line
               aria-hidden="true"
               style={{
-                '--trajectory-hover-left': `${hover * 100}%`,
+                '--trajectory-hover-left': `${hover.fraction * 100}%`,
               } as CSSProperties}
             />
           )}
@@ -313,7 +424,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
               />
             </>
           )}
-          <div className={css.turnBoundaries} aria-hidden="true">
+          <div
+            className={css.turnBoundaries}
+            data-animate-viewport={animateViewport || undefined}
+            aria-hidden="true"
+            style={projectedDomainStyle}
+          >
             {model.turnBoundaries
               .slice(1)
               .filter(boundary =>
@@ -326,24 +442,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
                   key={boundary.turn}
                   style={{
                     '--trajectory-turn-left':
-                      `${(boundary.time - domainStart) / domainDuration * 100}%`,
+                      `${(boundary.time - model.start) / fullDuration * 100}%`,
                   } as CSSProperties}
                 />
               ))}
           </div>
-          <div className={css.lanes} aria-hidden="true">
+          <div
+            className={css.lanes}
+            data-animate-viewport={animateViewport || undefined}
+            data-timeline-domain
+            aria-hidden="true"
+            style={projectedDomainStyle}
+          >
             {model.spans
-              .filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
+              .filter(span =>
+                span.index === selectedIndex
+                || (span.end >= domainStart && span.start <= domainStart + domainDuration))
               .map((span) => {
-                const left = (span.start - domainStart) / domainDuration
-                const width = (span.end - span.start) / domainDuration
+                const left = (span.start - model.start) / fullDuration
+                const width = (span.end - span.start) / fullDuration
                 const durationMs = durationByIndex.get(span.index)
                 return (
                   <span
                     className={css.span}
                     data-timeline-span={span.kind}
+                    data-timeline-record-index={span.index}
+                    data-error={span.isError || undefined}
                     data-equal-duration={mode === 'time' || undefined}
                     data-current={span.index === selectedIndex || undefined}
+                    data-hovered={hover?.recordIndex === span.index || undefined}
                     data-search-match={searchMatchIndexes === null
                       ? undefined
                       : searchMatchIndexes.has(span.index) ? 'true' : 'false'}

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

@@ -147,6 +147,9 @@ export function TrajectoryView({
   const [actualTime, setActualTime] = useState(false)
   const [searchQuery, setSearchQuery] = useState('')
   const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
+  const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
+    readonly index: number
+  } | null>(null)
   const ledgerRef = useRef<HTMLDivElement>(null)
   const inspection = useHistory(snapshot => snapshot.inspection)
   const nodes = inspection.eventNodes
@@ -478,7 +481,20 @@ export function TrajectoryView({
         selectedIndex={selectedTimelineIndex}
         searchMatchIndexes={searchMatchIndexes}
         onRangeChange={(range) => {
-          setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
+          setTimelineSelection(range === null ? null : {
+            branchId: currentBranch.id,
+            range,
+          })
+        }}
+        onRecordSelect={(index) => {
+          setTimelineSelection(null)
+          setTimelineRecordSelection({ index })
+          setSelectedTimelineIndex(index)
+          const row = ledgerRef.current
+            ?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
+          if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
+            row.scrollIntoView({ behavior: 'smooth', block: 'center' })
+          }
         }}
         onRecordFocus={(index) => {
           const row = ledgerRef.current
@@ -497,6 +513,7 @@ export function TrajectoryView({
           searchMatchIndexes={searchMatchIndexes}
           onSelectedIndexChange={setSelectedTimelineIndex}
           onRecordSelect={handleRecordSelect}
+          recordSelection={timelineRecordSelection}
           onClearSelection={() => { setTimelineSelection(null) }}
           collapsedTurns={collapsedTurns}
           onToggleTurn={toggleTurn}

+ 29 - 6
packages/client/ui-trajectory/src/client/layout.ts

@@ -12,6 +12,7 @@ import type {
   RequestView,
   ToolResultNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
+import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {
   TrajectoryCellProps,
   TrajectorySourceBlock,
@@ -66,6 +67,9 @@ interface TurnBucket {
   groups: LaidGroup[]
 }
 
+const PREVIEW_SOURCE_CHARACTERS = 2_048
+const PREVIEW_OUTPUT_CHARACTERS = 512
+
 type InputNode = Extract<
   ConversationSnapshot['nodes'][number],
   { kind: 'user' | 'steering' | 'context' }
@@ -126,6 +130,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
     nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
   } = input
   const resultByCall = indexResults(nodes)
+  const emittedCallIds = indexAssistantCallIds(nodes)
   const callStartById = new Map<string, number>()
   for (const result of resultByCall.values()) {
     const startedAt = finiteTime(result.callTime)
@@ -353,7 +358,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
       continue
     }
     if (node.kind === 'tool-result') {
-      if (!callEmittedInAssistant(nodes, node.callId)) {
+      if (!emittedCallIds.has(node.callId)) {
         const toolName = node.call?.name
         const laidList: LaidCell[] = [{
           absTime: finiteTime(node.callTime ?? node.time),
@@ -764,12 +769,15 @@ function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolRes
   return map
 }
 
-function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean {
+function indexAssistantCallIds(nodes: ConversationSnapshot['nodes']): ReadonlySet<string> {
+  const ids = new Set<string>()
   for (const node of nodes) {
     if (node.kind !== 'assistant') continue
-    if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true
+    for (const block of node.blocks) {
+      if (block.kind === 'tool-call') ids.add(block.callId)
+    }
   }
-  return false
+  return ids
 }
 
 function collectCallIds(
@@ -849,7 +857,7 @@ function expandSubCalls(
 }
 
 function summarizeCall(name: string, argsRaw: string): string {
-  const args = argsRaw.replace(/\s+/g, ' ').trim()
+  const args = trajectoryPreviewText(argsRaw)
   if (args === '') return name
   return `${name} · ${args}`
 }
@@ -907,5 +915,20 @@ function summarizeContent(content: readonly { type: string; text?: string }[]):
 }
 
 function summarizeText(text: string): string {
-  return text.replace(/\s+/g, ' ').trim()
+  return trajectoryPreviewText(text)
+}
+
+/**
+ * Build a bounded one-line ledger preview without parsing the complete Markdown document.
+ * Full source remains on the cell for the inspector.
+ * @param text - Untrusted message, reasoning, payload, or result text.
+ * @returns A compact preview capped independently from the retained source.
+ */
+export function trajectoryPreviewText(text: string): string {
+  const source = text.slice(0, PREVIEW_SOURCE_CHARACTERS)
+  const compact = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim()
+  const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd()
+  return source.length < text.length || preview.length < compact.length
+    ? `${preview}…`
+    : preview
 }

+ 3 - 0
packages/client/ui-trajectory/src/client/timeline.ts

@@ -15,6 +15,7 @@ export interface TrajectoryTimeRange {
 /** One ledger record projected into the active timeline domain. */
 export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
   index: number
+  isError: boolean
   kind: TrajectoryCellKind
   label: string
   lane: number
@@ -94,6 +95,7 @@ export function deriveTrajectoryTimeline(
       start: spans.length + offset,
       end: spans.length + offset + 1,
       index: cell.index,
+      isError: cell.isError === true,
       kind: cell.kind,
       label: cell.text,
       lane: laneFor(cell.kind),
@@ -129,6 +131,7 @@ function deriveTimedTimeline(
           : [{
             ...range,
             index: cell.index,
+            isError: cell.isError === true,
             kind: cell.kind,
             label: cell.text,
             lane: laneFor(cell.kind),

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

@@ -176,6 +176,25 @@ describe('deriveTrajectoryLayout', () => {
     })
   })
 
+  it('bounds a long Markdown-like thinking preview while retaining its full detail', () => {
+    const thinking = `# Investigation\n\n**NAVIGATION_OK file_path** ${'- repeated detail '.repeat(1_000)}`
+    const nodes = [{
+      kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
+      blocks: [{ kind: 'reasoning', text: thinking }],
+    }] as unknown as ConversationSnapshot['nodes']
+
+    const turns = deriveTrajectoryLayout({
+      codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
+    })
+    const message = turns[0]?.groups.flatMap(group => group.cells)
+      .find(cell => cell.kind === 'message')
+
+    expect(message?.text.startsWith('Investigation NAVIGATION_OK file_path')).toBe(true)
+    expect(message?.text.endsWith('…')).toBe(true)
+    expect(message?.text.length).toBeLessThanOrEqual(513)
+    expect(message?.thinkingDetail).toBe(thinking)
+  })
+
   it('advances the duration cursor over context nodes', () => {
     const nodes = [
       { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },

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

@@ -83,6 +83,31 @@ describe('TrajectoryTable', () => {
     expect(screen.getByText('15 tok')).toBeTruthy()
   })
 
+  it('keeps long thinking collapsed until the user asks to render it', () => {
+    const thinking = 'private chain '.repeat(1_000)
+    const turns: readonly TrajectoryTurnModel[] = [{
+      turn: 1,
+      groups: [{
+        title: 'Step 1',
+        cells: [{
+          index: 1,
+          kind: 'message',
+          text: 'private chain…',
+          thinkingDetail: thinking,
+          timeSeconds: 1,
+        }],
+      }],
+    }]
+    render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
+
+    fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
+    const toggle = screen.getByRole('button', { name: 'Thinking ...' })
+    expect(screen.queryByText(thinking)).toBeNull()
+
+    fireEvent.click(toggle)
+    expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
+  })
+
   it('keeps raw HTML tags in a Markdown-derived context preview', () => {
     const html = [
       '<background-task-complete id="trajectory-ui-watch">',
@@ -144,8 +169,53 @@ describe('TrajectoryTable', () => {
     expect(screen.getByText('Pending')).toBeTruthy()
     fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ }))
     expect(screen.getByText('Failed')).toBeTruthy()
+    expect(screen.getByText('Failed').className).toContain('error')
     fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
-    expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy()
+    const errorResult = screen.getByText('ToolError: non_zero_exit')
+    expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
+  })
+
+  it('renders responsive role icons with a custom tooltip', () => {
+    const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
+    const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
+
+    expect(toolTag).not.toBeNull()
+    expect(toolTag?.getAttribute('title')).toBeNull()
+    expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
+
+    fireEvent.mouseEnter(toolTag as HTMLElement)
+    expect(screen.getByRole('tooltip').textContent).toBe('TOOL')
+    fireEvent.mouseLeave(toolTag as HTMLElement)
+    expect(screen.queryByRole('tooltip')).toBeNull()
+  })
+
+  it('uses information and compression glyphs for injected and compacted context', () => {
+    const turns: readonly TrajectoryTurnModel[] = [{
+      turn: 1,
+      groups: [{
+        title: 'Context',
+        cells: [
+          { index: 1, kind: 'context', text: 'Workspace context', timeSeconds: 0 },
+          { index: 2, kind: 'compacted', text: 'Compacted history', timeSeconds: 0 },
+        ],
+      }],
+    }]
+    const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
+
+    expect(view.container.querySelector(
+      '[data-role-kind="context"] [data-role-icon="information"]',
+    )).toBeTruthy()
+    expect(view.container.querySelector(
+      '[data-role-kind="compacted"] [data-role-icon="compacted"]',
+    )).toBeTruthy()
+  })
+
+  it('keeps a compact turn label available for narrow layouts', () => {
+    render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
+    const turnLabel = screen.getByLabelText('Turn 1')
+
+    expect(turnLabel.textContent).toContain('Turn 1')
+    expect(turnLabel.textContent).toContain('#1')
   })
 
   it('renders a single-text JSON tool result as a JSON tree', () => {

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

@@ -25,6 +25,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
 import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
 import type { TrajectoryTurnModel } from '../src/client/layout.ts'
+import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
 import {
   TrajectoryView, type TrajectoryViewInjected,
 } from '../src/client/TrajectoryView.tsx'
@@ -309,6 +310,44 @@ describe('tab switching in ConversationRoot', () => {
       .toBeNull()
   })
 
+  it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
+    const b = await bench()
+    const view = mount(b.slots)
+    fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
+    const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
+    vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
+      x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
+      toJSON: () => ({}),
+    })
+    const toolSpan = view.container.querySelector<HTMLElement>(
+      '[data-timeline-span="tool"]',
+    )
+    expect(toolSpan).not.toBeNull()
+    const recordIndex = toolSpan?.dataset.timelineRecordIndex
+    expect(recordIndex).toBeTruthy()
+
+    fireEvent.pointerMove(toolSpan as HTMLElement, { clientX: 50, pointerId: 1 })
+    expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
+    expect(toolSpan?.getAttribute('data-hovered')).toBe('true')
+
+    fireEvent.pointerDown(plot, { button: 0, clientX: 5, pointerId: 1 })
+    fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
+    fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
+    expect(view.container.querySelector('tr[data-timeline-focus]')).toBeTruthy()
+
+    fireEvent.pointerDown(toolSpan as HTMLElement, {
+      button: 0, clientX: 50, pointerId: 2,
+    })
+    fireEvent.pointerUp(toolSpan as HTMLElement, { clientX: 50, pointerId: 2 })
+
+    const selectedRow = view.container.querySelector<HTMLElement>(
+      `tr[data-record-index="${recordIndex}"]`,
+    )
+    expect(selectedRow?.getAttribute('aria-selected')).toBe('true')
+    expect(view.container.querySelector('tr[data-timeline-focus]')).toBeNull()
+    expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
+  })
+
   it('empty window keeps the toolbar and reports no timing data', async () => {
     const b = await bench(historySnapshot([]))
     mount(b.slots)
@@ -332,6 +371,97 @@ describe('timeline projection', () => {
       ],
     }],
   }] satisfies readonly TrajectoryTurnModel[]
+  const longTurns = [{
+    turn: 1,
+    groups: [{
+      title: 'Step 1',
+      cells: Array.from({ length: 10 }, (_, index) => ({
+        index,
+        kind: 'message' as const,
+        text: `record ${index}`,
+        timeSeconds: 1,
+      })),
+    }],
+  }] satisfies readonly TrajectoryTurnModel[]
+
+  it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
+    const onRangeChange = vi.fn()
+    const view = render(
+      <TrajectoryTimeline
+        turns={longTurns}
+        mode="sequence"
+        range={null}
+        onRangeChange={onRangeChange}
+      />,
+    )
+    const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
+    vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
+      x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
+      toJSON: () => ({}),
+    })
+    fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
+
+    view.rerender(
+      <TrajectoryTimeline
+        turns={longTurns}
+        mode="sequence"
+        range={null}
+        selectedIndex={1}
+        onRangeChange={onRangeChange}
+      />,
+    )
+    await vi.waitFor(() => {
+      const domain = view.container.querySelector<HTMLElement>(
+        '[data-timeline-domain]',
+      )
+      expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-25%')
+    })
+
+    view.rerender(
+      <TrajectoryTimeline
+        turns={longTurns}
+        mode="sequence"
+        range={null}
+        selectedIndex={8}
+        onRangeChange={onRangeChange}
+      />,
+    )
+    await vi.waitFor(() => {
+      const domain = view.container.querySelector<HTMLElement>(
+        '[data-timeline-domain]',
+      )
+      expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-125%')
+    })
+  })
+
+  it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
+    const onRangeChange = vi.fn()
+    render(
+      <TrajectoryTimeline
+        turns={longTurns}
+        mode="sequence"
+        range={null}
+        onRangeChange={onRangeChange}
+      />,
+    )
+    const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
+    vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
+      x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
+      toJSON: () => ({}),
+    })
+    fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
+    fireEvent.pointerDown(plot, { button: 0, clientX: 50, pointerId: 1 })
+    for (let index = 0; index < 24; index++) {
+      fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
+    }
+    fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
+
+    const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
+      | { start: number; end: number }
+      | undefined
+    expect(selectedRange).toBeDefined()
+    expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
+  })
 
   it('uses equal-width operation slots and stable semantic lanes', () => {
     expect(deriveTrajectoryTimeline(turns)).toEqual({
@@ -339,15 +469,50 @@ describe('timeline projection', () => {
       end: 3,
       spans: [
         {
-          index: 1, kind: 'message', label: 'assistant', lane: 1, start: 0, end: 1,
+          index: 1, isError: false, kind: 'message', label: 'assistant',
+          lane: 1, start: 0, end: 1,
+        },
+        {
+          index: 2, isError: false, kind: 'tool', label: 'bash',
+          lane: 2, start: 1, end: 2,
+        },
+        {
+          index: 3, isError: false, kind: 'user', label: 'unknown',
+          lane: 0, start: 2, end: 3,
         },
-        { index: 2, kind: 'tool', label: 'bash', lane: 2, start: 1, end: 2 },
-        { index: 3, kind: 'user', label: 'unknown', lane: 0, start: 2, end: 3 },
       ],
       turnBoundaries: [{ turn: 1, time: 0 }],
     })
   })
 
+  it('marks error records directly on timeline spans', () => {
+    const errorTurns = [{
+      turn: 1,
+      groups: [{
+        title: 'Step 1',
+        cells: [{
+          index: 1,
+          kind: 'tool' as const,
+          text: 'failed tool',
+          timeSeconds: 0.1,
+          isError: true,
+        }],
+      }],
+    }] satisfies readonly TrajectoryTurnModel[]
+    const view = render(
+      <TrajectoryTimeline
+        turns={errorTurns}
+        mode="sequence"
+        range={null}
+        onRangeChange={() => {}}
+      />,
+    )
+
+    expect(view.container.querySelector(
+      '[data-timeline-span="tool"][data-error="true"]',
+    )).toBeTruthy()
+  })
+
   it('ignores durations and idle gaps while retaining turn boundaries', () => {
     const separatedTurns = [
       {