Przeglądaj źródła

perf(trajectory): page resident history before rendering

imccyu 4 tygodni temu
rodzic
commit
2ab37e9558

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.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 .agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md
-2026-08-11-trajectory-conversation-context-assembly.md: c041d7a8ba9f39f74cf55f8a35513724eaeb4f54
-2026-08-11-trajectory-conversation-context-assembly.zh.md: 06e7b801b67b789ddffe991916aceee86cc903f8
+2026-08-11-trajectory-conversation-context-assembly.md: 6a8c41598533d1b277548b04e003c9eaa956f0b0
+2026-08-11-trajectory-conversation-context-assembly.zh.md: 1f5b5eb7ab086adf9f6c91f670ac99ad2c897a1b

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md

@@ -74,7 +74,7 @@ The Context migration and the following presentation optimizations solve differe
 | Following Assistant lookup | One reverse pass records the next Assistant for every input position | The former repeated forward lookup falls from worst-case `O(C²)` to `O(C)` |
 | Group duration | Fixed decimal grouping replaces `toLocaleString('en-US')` for the invariant English numeric shape | Complexity remains linear in Groups, but the Intl formatter leaves the repeated render path |
 
-Display memoization and search indexing stay separate. Search must include off-screen records and may lag live changes by the throttle interval; Table rendering must update the visible changed record immediately and must not inherit the index's commit cadence.
+Display memoization and search indexing stay separate. Search includes off-screen records in the current React-visible history window and may lag live changes by the throttle interval; Table rendering must update the visible changed record immediately and must not inherit the index's commit cadence.
 
 ## Alternatives considered
 
@@ -88,7 +88,7 @@ Display memoization and search indexing stay separate. Search must include off-s
 
 **Replace the Trajectory stages with generic Conversation Nodes.** Rejected: stages organize requests, timing, schemas, and table layout for one view. Making them engine contracts would constrain a future plain Session-log view and return view-specific composition to Client Runtime.
 
-**Share one Markdown cache between display and search.** Rejected: display is immediate and viewport-bound, while search covers the complete loaded record set and intentionally batches updates. A shared cache would couple correctness and scheduling across unrelated consumers.
+**Share one Markdown cache between display and search.** Rejected: display is immediate and viewport-bound, while search covers the complete React-visible record set and intentionally batches updates. A shared cache would couple correctness and scheduling across unrelated consumers.
 
 ## Verification
 
@@ -100,7 +100,7 @@ Trajectory Definition and Builder tests pin Assistant streaming and interruption
 
 Trajectory business assembly now scales with the changed page or keyed Context instead of restarting from the complete raw Event window. Target-owned Definitions can evolve independently from Chat while retaining one Session window and one set of lifecycle rules. Steering becomes a first-class Trajectory record at its actual Step position without adding steering-specific state to Session.
 
-After first activation, the retained stage-oriented Builder still performs work proportional to materialized Trajectory contributions and may sort on publication. Before activation, the target retains Context State and one target index but no Builder, materialized Node, or snapshot. The search index still performs a light linear signature pass when its input layout changes.
+After first activation, the retained stage-oriented Builder still performs work proportional to materialized Trajectory contributions and may sort on publication. Before activation, the target retains Context State and one target index but no Builder, materialized Node, or snapshot. Each Trajectory view mount anchors React layout, timeline, and search data to 50 target Nodes at the current tail; live appends extend that window, and the existing earlier-history action extends its prefix before it requests another Session page. Request numbering and cumulative usage remain derived from the complete resident snapshot. The search index still performs a light linear signature pass when its input layout changes.
 
 Definition authors must provide stable protocol identities. Old Events without a required ID can disappear from the affected Trajectory business view, which is preferable to joining unrelated records or failing history load; producers that require faithful display must log the identity.
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md

@@ -74,7 +74,7 @@ Context 迁移与下列表现层优化解决的是不同成本。这些优化保
 | 后继 Assistant 查找 | 一次反向遍历为每个输入位置记录后续 Assistant | 原先重复向前查找的最坏复杂度从 `O(C²)` 降为 `O(C)` |
 | Group duration | 以固定十进制分组替代固定英文数字形态下的 `toLocaleString('en-US')` | 复杂度仍与 Group 数线性相关,但重复 render 路径不再调用 Intl formatter |
 
-展示 memo 与搜索索引彼此独立。搜索必须覆盖屏幕外 record,并允许实时变化延迟一个 throttle 周期;Table 必须立即更新发生变化的可见 record,不能继承索引的提交节奏。
+展示 memo 与搜索索引彼此独立。搜索覆盖当前 React 可见历史窗口中的屏幕外 record,并允许实时变化延迟一个 throttle 周期;Table 必须立即更新发生变化的可见 record,不能继承索引的提交节奏。
 
 ## 考虑过的替代方案
 
@@ -88,7 +88,7 @@ Context 迁移与下列表现层优化解决的是不同成本。这些优化保
 
 **用通用 Conversation Node 替换 Trajectory stage。** 不予采纳:stage 为单一视图组织 Request、计时、schema 和表格 layout。把它变成引擎约定会限制未来的朴素 Session-log 视图,并把视图专属组合重新放回 Client Runtime。
 
-**在展示与搜索之间共享一套 Markdown cache。** 不予采纳:展示要求立即更新且受 viewport 约束,搜索则覆盖全部已加载 record,并有意批量提交更新。共享 cache 会把两个无关消费方的正确性与调度节奏耦合起来。
+**在展示与搜索之间共享一套 Markdown cache。** 不予采纳:展示要求立即更新且受 viewport 约束,搜索则覆盖全部 React 可见 record,并有意批量提交更新。共享 cache 会把两个无关消费方的正确性与调度节奏耦合起来。
 
 ## 验证
 
@@ -100,7 +100,7 @@ Trajectory Definition 与 Builder 测试固定 Assistant streaming 与 interrupt
 
 Trajectory 业务组装的成本随变化页面或 keyed Context 增长,不再从完整原始 Event 窗口重新开始。target 自有 Definition 可以独立于 Chat 演进,同时继续共享一份 Session 窗口和一套生命周期规则。steering 会在实际所属 Step 位置成为一等 Trajectory record,不需要向 Session 增加 steering 专属状态。
 
-首次激活后,保留的 stage-oriented Builder 仍会执行与已物化 Trajectory contribution 数量成正比的工作,并可能在发布时排序。激活前,target 保留 Context State 和一个 target 索引,但不保留 Builder、已物化 Node 或 snapshot。输入 layout 变化时,搜索索引仍会执行一次轻量线性签名检查。
+首次激活后,保留的 stage-oriented Builder 仍会执行与已物化 Trajectory contribution 数量成正比的工作,并可能在发布时排序。激活前,target 保留 Context State 和一个 target 索引,但不保留 Builder、已物化 Node 或 snapshot。每次挂载 Trajectory 视图时,React layout、timeline 与搜索数据都锚定在当前尾部的 50 个 target Node;实时 append 会扩展该窗口,现有更早历史操作则先扩展其前缀,再请求下一个 Session 页面。请求编号与累计用量仍从完整的驻留 snapshot 派生。输入 layout 变化时,搜索索引仍会执行一次轻量线性签名检查。
 
 Definition 作者必须提供稳定的协议标识。缺少必要 ID 的旧 Event 可能不会出现在受影响的 Trajectory 业务视图中;与合并无关记录或让历史加载失败相比,这是更安全的退化方式。要求完整展示的生产方必须记录该标识。
 

+ 6 - 1
apps/web/tests/trajectory-virtualization.e2e.ts

@@ -255,6 +255,11 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
       await compareOrRefreshGolden(LOAD_MORE_EXPECTED, loadMoreSnapshot, MODE)
       // Avoid Playwright scrolling the offscreen first row into the automatic-load threshold.
       await loadMore.evaluate((button: HTMLButtonElement) => { button.click() })
+      await expect.poll(() => logicalRows(page), { timeout: 15_000 }).toBeGreaterThan(initialRows)
+      const residentRows = await logicalRows(page)
+      expect(held).toBe(false)
+      await expect.poll(() => loadMore.isDisabled(), { timeout: 15_000 }).toBe(false)
+      await loadMore.evaluate((button: HTMLButtonElement) => { button.click() })
       await expect.poll(() => held, { timeout: 15_000 }).toBe(true)
       await expect.poll(async () => ({
         disabled: await loadMore.isDisabled(),
@@ -274,7 +279,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
         .toBe('true')
 
       releaseHistory()
-      await expect.poll(() => logicalRows(page), { timeout: 60_000 }).toBeGreaterThan(initialRows)
+      await expect.poll(() => logicalRows(page), { timeout: 60_000 }).toBeGreaterThan(residentRows)
       await nextPaint(page)
       await expect.poll(async () => {
         const top = await rowTop(page, anchor.key)

+ 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;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
-README.md: 02daa3a7e4440b4ea4901f98139044bbda68827f
-README.zh.md: 0a1aa49a94d68cc1ab56e02831e4cbf06be5fdb1
+README.md: d947787c123fca77dc7978456f4cfdc4e99951bf
+README.zh.md: 3b281c6c1e90108d66f050e07a3b08208ed8c802

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

@@ -29,7 +29,7 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit
 
 ### Inspecting records
 
-Selection, timeline navigation, folding, search, and Request totals cover the loaded window. 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 record without text labels its row with the image count. 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 record without text labels its row with the image count. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn.
 
 ### The timing overview
 
@@ -47,7 +47,7 @@ The view is a pure projection: Trajectory-owned Definitions assemble business re
 
 ### Virtual rows
 
-Long ledgers mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events.
+Long ledgers initially derive React data from 50 target Nodes ending at the mount-time tail. Later Nodes extend that anchored window without evicting its prefix, and the existing load control reveals earlier resident Nodes before requesting another Session page. Virtualization mounts only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events.
 
 ### Layout
 

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

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 检查记录
 
-选择、时间线导航、折叠、搜索与请求汇总只覆盖已加载的窗口。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。没有文本的记录会用图片数量标记其行。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。
+选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留 snapshot。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。没有文本的记录会用图片数量标记其行。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。
 
 ### 时间概览
 
@@ -47,7 +47,7 @@ kind: "package-reference"
 
 ### 虚拟行
 
-长记录表只挂载可见行窗口加少量缓冲;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键与 ARIA 索引在向前补页后保持不变。仅含内容更新的流式帧会保持虚拟行的键与高度、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。
+长记录表最初只从挂载时尾部结束的 50 个 target Node 派生 React 数据。后续 Node 会扩展这个固定起点的窗口而不会逐出其前缀;现有加载控件会先显露更早的驻留 Node,再请求下一个 Session 页面。虚拟化只挂载可见行窗口加少量缓冲;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键与 ARIA 索引在向前补页后保持不变。仅含内容更新的流式帧会保持虚拟行的键与高度、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。
 
 ### 布局
 

+ 55 - 9
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -3,6 +3,7 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import type {
   AssistantBlock, AssistantMessageNode, ConvViewProps, MessageImageLoader, RenderMessageImages,
+  ToolCallBlock,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -30,6 +31,14 @@ import css from './views.module.css'
 const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
 const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set()
 const SEARCH_INDEX_THROTTLE_MS = 3_000
+const HISTORY_PAGE_NODES = 50
+
+function containsCall(calls: readonly ToolCallBlock[], callId: string): boolean {
+  for (const call of calls) {
+    if (call.callId === callId || containsCall(call.subCalls, callId)) return true
+  }
+  return false
+}
 
 function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
   let last = 0
@@ -146,10 +155,35 @@ export function TrajectoryView({
   const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
     readonly index: number
   } | null>(null)
-  const inspection = useTrajectory(snapshot => snapshot)
+  const completeInspection = useTrajectory(snapshot => snapshot)
+  const latestNodeSeq = completeInspection.eventNodes.at(-1)?.seq
+  const [historyTailSeq, setHistoryTailSeq] = useState(latestNodeSeq)
+  const [historyNodeLimit, setHistoryNodeLimit] = useState(HISTORY_PAGE_NODES)
+  const fixedTailSeq = historyTailSeq ?? latestNodeSeq
+  const historyEndIndex = fixedTailSeq === undefined
+    ? 0
+    : completeInspection.eventNodes.findLastIndex(node => node.seq <= fixedTailSeq) + 1
+  const historyStartIndex = Math.max(0, historyEndIndex - historyNodeLimit)
+  useEffect(() => {
+    if (historyTailSeq === undefined && latestNodeSeq !== undefined) setHistoryTailSeq(latestNodeSeq)
+  }, [historyTailSeq, latestNodeSeq])
+  const inspection = useMemo<TrajectorySnapshot>(() => {
+    if (historyStartIndex === 0) return completeInspection
+    const eventNodes = completeInspection.eventNodes.slice(historyStartIndex)
+    const firstSeq = eventNodes[0]?.seq ?? 0
+    return {
+      ...completeInspection,
+      eventNodes,
+      requests: completeInspection.requests.filter(request =>
+        request.startSeq >= firstSeq || (request.resultSeq ?? -1) >= firstSeq),
+    }
+  }, [completeInspection, historyStartIndex])
   const historyLoading = useSession(snapshot => snapshot.openState === 'loading')
   const olderHistoryLoading = useSession(snapshot => snapshot.loadingOlder)
-  const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
+  const sessionHasOlderHistory = useSession(snapshot => snapshot.hasMore)
+  const hasResidentOlderHistory = historyStartIndex > 0
+  const hasOlderHistory = hasResidentOlderHistory
+    || sessionHasOlderHistory
   const nodes = inspection.eventNodes
   const eventLocations = inspection.eventLocations
   const historyBaseSeq = nodes[0]?.seq ?? 0
@@ -158,14 +192,24 @@ export function TrajectoryView({
   const requests = inspection.requests
   const callSchemas = inspection.callSchemas
   const inspectCallId = viewRequest?.view === 'trajectory' ? viewRequest.focus : null
+  const inspectNodeIndex = useMemo(() => inspectCallId === null
+    ? -1
+    : completeInspection.eventNodes.findIndex(node => node.kind === 'assistant'
+      ? node.blocks.some(block => block.kind === 'tool-call' && block.callId === inspectCallId)
+      : node.kind === 'tool-result' && containsCall([node], inspectCallId)),
+  [completeInspection.eventNodes, inspectCallId])
+  useEffect(() => {
+    if (inspectNodeIndex < 0 || inspectNodeIndex >= historyStartIndex) return
+    setHistoryNodeLimit(limit => limit + historyStartIndex - inspectNodeIndex)
+  }, [historyStartIndex, inspectNodeIndex])
   const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
     const assistantsByStep = new Map<string, AssistantMessageNode>()
-    for (const node of nodes) {
+    for (const node of completeInspection.eventNodes) {
       if (node.kind !== 'assistant' || node.step <= 0) continue
       assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
     }
     const requestsByStep = new Map(
-      requests
+      completeInspection.requests
         .filter(request => request.purpose === 'assistant')
         .map(request => [
           `${request.turn}\u0000${request.step}`,
@@ -173,7 +217,7 @@ export function TrajectoryView({
         ]),
     )
     const orderedRequests = [
-      ...requests.map(request => ({
+      ...completeInspection.requests.map(request => ({
         seq: request.startSeq,
         request,
         node: request.purpose === 'assistant'
@@ -257,7 +301,7 @@ export function TrajectoryView({
 
     return numbered
   }, [
-    nodes, requests, t,
+    completeInspection.eventNodes, completeInspection.requests, t,
   ])
   const partialTurn = partial?.turn ?? null
   const partialStep = partial?.step ?? null
@@ -448,9 +492,11 @@ export function TrajectoryView({
     })
   }
 
-  const loadEarlierHistory = useCallback(() => {
-    return loadOlder()
-  }, [loadOlder])
+  const loadEarlierHistory = useCallback(async () => {
+    if (!hasResidentOlderHistory && !await loadOlder()) return false
+    setHistoryNodeLimit(limit => limit + HISTORY_PAGE_NODES)
+    return true
+  }, [hasResidentOlderHistory, loadOlder])
 
   return (
     <div className={css.root} data-conversation-composer-overlay="">

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

@@ -8,7 +8,7 @@
  * Timeline projection and inclusive focus edge cases ride along.
  */
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
 import { bindSnapshotSelector, SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -66,6 +66,8 @@ const runtimes: SlotTestRuntime[] = []
 
 afterEach(async () => {
   cleanup()
+  vi.restoreAllMocks()
+  Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
   for (const runtime of runtimes.splice(0)) await runtime.dispose()
 })
 // The Conversation store persists under its declared key; clear so one case's active
@@ -1264,6 +1266,154 @@ describe('timeline projection', () => {
 })
 
 describe('TrajectoryView state', () => {
+  it('reveals resident history one bounded page at a time', async () => {
+    const nodes: LegacyConversationSlice['nodes'] = Array.from({ length: 5_000 }, (_, index) => ({
+      kind: 'user' as const,
+      seq: index + 1,
+      time: index + 1,
+      content: [{ type: 'text' as const, text: `prompt ${String(index + 1)}` }],
+      source: null,
+    }))
+    const trajectory = createSnapshotStore(historySnapshot([]))
+    const loadOlder = vi.fn(() => Promise.resolve(false))
+    render(
+      <TrajectoryView
+        {...standaloneProps([])}
+        {...standaloneHistory(historySnapshot(nodes))}
+        {...standaloneDuration()}
+        useTrajectory={bindSnapshotSelector(trajectory)}
+        loadOlder={loadOlder}
+      />,
+    )
+
+    act(() => { trajectory.set(historySnapshot(nodes)) })
+    expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('51')
+    fireEvent.click(screen.getAllByRole('button', { name: '加载更早的历史' }).at(-1)!)
+    expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('101')
+    expect(loadOlder).not.toHaveBeenCalled()
+
+    act(() => { trajectory.set(historySnapshot([...nodes, {
+      kind: 'user', seq: 5_001, time: 5_001,
+      content: [{ type: 'text', text: 'appended prompt' }], source: null,
+    }])) })
+    expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('102')
+  })
+
+  it('keeps resident request numbering and cumulative usage outside the layout page', async () => {
+    vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
+    Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
+      configurable: true,
+      value: vi.fn(),
+    })
+    const visibleNodes: LegacyConversationSlice['nodes'] = Array.from(
+      { length: 10 },
+      (_, index) => ({
+        kind: 'assistant' as const,
+        seq: index * 2 + 44,
+        time: index * 2 + 44,
+        turn: index + 2,
+        step: 1,
+        blocks: [{ kind: 'text' as const, text: `visible response ${String(index + 1)}` }],
+      }),
+    )
+    const nodes: LegacyConversationSlice['nodes'] = [{
+      kind: 'assistant', seq: 2, time: 2, turn: 1, step: 1,
+      blocks: [{ kind: 'tool-call', callId: 'boundary-call', name: 'bash', argsRaw: '{}' }],
+    }, {
+      kind: 'tool-result', seq: 3, time: 3, callId: 'boundary-call',
+      call: { name: 'bash', argsRaw: '{}' }, callTime: 2,
+      content: [], isError: false, subCalls: [],
+    }, ...Array.from({ length: 39 }, (_, index) => ({
+      kind: 'compaction' as const,
+      seq: index + 4,
+      time: index + 4,
+      summary: null,
+      summaryEventSeq: null,
+      shadowedItemCount: null,
+      shadowedTokenCount: null,
+    })), ...visibleNodes]
+    const requests: readonly RequestView[] = [{
+      purpose: 'assistant', startSeq: 1, resultSeq: 2, startedAt: 1, completedAt: 2,
+      status: 'complete', turn: 1, step: 1, usage: { inputTokens: 1 },
+    }, ...visibleNodes.map((node, index) => ({
+      purpose: 'assistant' as const,
+      startSeq: node.seq - 1,
+      resultSeq: node.seq,
+      startedAt: node.seq - 1,
+      completedAt: node.seq,
+      status: 'complete' as const,
+      turn: index + 2,
+      step: 1,
+      usage: { inputTokens: 1 },
+    }))]
+    render(
+      <TrajectoryView
+        {...standaloneProps([])}
+        {...standaloneHistory(historySnapshot(nodes, { requests }))}
+        {...standaloneDuration()}
+      />,
+    )
+
+    await waitFor(() => { expect(screen.getByRole('button', { name: '请求 #1' })).toBeTruthy() })
+    await waitFor(() => { expect(screen.getByRole('button', { name: '请求 #11' })).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: '请求 #11' }))
+    fireEvent.click(screen.getByRole('tab', { name: '用量' }))
+    expect(screen.getByText('会话累计').closest('section')?.textContent).toContain('11 tok')
+  })
+
+  it.each([
+    ['root', {
+      kind: 'assistant' as const,
+      seq: 3,
+      time: 3,
+      turn: 1,
+      step: 1,
+      blocks: [{ kind: 'tool-call' as const, callId: 'hidden-root', name: 'bash', argsRaw: '{}' }],
+    }, 'hidden-root'],
+    ['nested', {
+      kind: 'tool-result' as const,
+      seq: 3,
+      time: 3,
+      callId: 'hidden-root',
+      call: { name: 'run_code', argsRaw: '{}' },
+      callTime: 2,
+      content: [],
+      isError: false,
+      subCalls: [{
+        callId: 'hidden-child', parentCallId: 'hidden-root', name: 'bash', argsRaw: '{}',
+        turn: 1, step: 1, time: 3, subCalls: [],
+      }],
+    }, 'hidden-child'],
+  ])('reveals a hidden resident %s call for cross-view inspection', (_kind, target, focus) => {
+    const nodes: LegacyConversationSlice['nodes'] = [
+      { kind: 'user', seq: 1, time: 1, content: [], source: null },
+      {
+        kind: 'tool-result', seq: 2, time: 2, callId: 'unrelated', call: null, callTime: null,
+        content: [], isError: false, subCalls: [],
+      },
+      target,
+      ...Array.from({ length: 50 }, (_, index) => ({
+        kind: 'user' as const,
+        seq: index + 4,
+        time: index + 4,
+        content: [{ type: 'text' as const, text: `later ${String(index + 1)}` }],
+        source: null,
+      })),
+    ]
+    const completeViewRequest = vi.fn()
+    render(
+      <TrajectoryView
+        {...standaloneProps([])}
+        {...standaloneHistory(historySnapshot(nodes))}
+        {...standaloneDuration()}
+        viewRequest={{ view: 'trajectory', focus }}
+        completeViewRequest={completeViewRequest}
+      />,
+    )
+
+    expect(completeViewRequest).toHaveBeenCalledOnce()
+  })
+
   it('persists the duration preference through the runtime snapshot-store seam', () => {
     const firstDuration = createTrajectoryDurationStore()
     const commonProps = {