From 835156b0384a1f1dc6a740bdb320d08614cfde17 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:20:59 +0800 Subject: [PATCH] fix(ui-trajectory): timing provenance on sub-span lanes; assembled snapshot for both views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responding to ds-review-bot on #664: - SubSpanLane gains a 'timing' discriminant (measured | running | unknown). A settle-only replay entry (callTime null, start outside the window) was previously indistinguishable from a measured 0 ms span; it now renders hollow with a 'duration unknown' hover title, and durationMs stays null for anything unmeasured. Pairs with the client-runtime fix that stopped fabricating callTime = settle time (826c3696a on the live-parallel PR). - The built-client Code Mode fixture snapshot now switches to the Trajectory and Waterfall tabs and pins the assembled rendering: three Sub cells with real +0.8s durations and three measured lanes with their hover titles — product-visible coverage through the real bundle graph, not just package-level jsdom fixtures. Agent Note (both languages) updated for the timing contract; pairing re-recorded. --- ...-mode-trajectory-waterfall-spans.i18n.yaml | 4 +- ...26-code-mode-trajectory-waterfall-spans.md | 4 +- ...code-mode-trajectory-waterfall-spans.zh.md | 4 +- apps/web/tests/code-mode-fixture.snapshot.ts | 64 ++++++++++++++++++- .../src/client/WaterfallView.tsx | 7 +- .../client/ui-trajectory/src/client/spans.ts | 15 ++++- .../ui-trajectory/src/client/views.module.css | 8 ++- .../client/ui-trajectory/tests/views.spec.tsx | 41 +++++++++++- 8 files changed, 133 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml index ac38a7465f..233e1ce72a 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.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 -2026-07-26-code-mode-trajectory-waterfall-spans.md: 54449bcf8612a39461a769173d7f60c742f67ad8 -2026-07-26-code-mode-trajectory-waterfall-spans.zh.md: fbfb26c3a62554d60a6cb561ead78e10cd4115cd +2026-07-26-code-mode-trajectory-waterfall-spans.md: fe4dcc25dbf211cf69e0d33937cf87a7482852e2 +2026-07-26-code-mode-trajectory-waterfall-spans.zh.md: aaae06b1fca1b5587d06aa7704adec421d2b2c27 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md index 54449bcf86..fe4dcc25db 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md @@ -15,7 +15,7 @@ Trajectory and waterfall still rendered a `run_code` turn as one opaque Tool cel **Trajectory: `subtool` cells interleaved after their parent Tool cell. Waterfall: real-time sub-lanes under the owning turn row.** - **Trajectory**: the layout fold takes the snapshot's `codeDispatches` index; after each Tool cell whose `callId` has dispatches (assistant-block calls, orphan results, and running calls alike), it interleaves one `subtool` cell per sub-dispatch in start order — indexes stay sequential across the interleave. A settled sub-call's duration is its start/settle pair (`durationSeconds(sub.time, sub.callTime)`); a running one shows the em dash, exactly the native in-flight convention. The new cell kind wears a `Sub` tag (business tint) and a 28px indent so nesting reads at a glance. -- **Waterfall**: `deriveSubSpans` folds the dispatch index into per-turn lanes with REAL timing — each parent's dispatch window is first start → last settle, and every lane's offset/width is its fraction of that window, so parallel sub-calls (PR3) visibly overlap. Running lanes extend to the window end at reduced opacity with a null duration. Lanes draw under the owning turn's bar row, scaled into a fixed lane budget. +- **Waterfall**: `deriveSubSpans` folds the dispatch index into per-turn lanes with REAL timing — each parent's dispatch window is first start → last settle, and every lane's offset/width is its fraction of that window, so parallel sub-calls (PR3) visibly overlap. Each lane carries a `timing` provenance tag: `measured` (pair observed), `running` (settle pending — extends to the window end at reduced opacity), or `unknown` (settle-only replay window, `callTime: null` — drawn hollow and titled "duration unknown", never a fabricated 0 ms). Lanes draw under the owning turn's bar row, scaled into a fixed lane budget. - Both views read `codeDispatches` through the standard snapshot hook — no new wire data, no new stores; replay renders identically to live by construction. ## Alternatives considered @@ -28,4 +28,4 @@ Trajectory and waterfall still rendered a `run_code` turn as one opaque Tool cel ## Consequences -The waterfall carries the first REAL wall-time rendering in the client (turn bars remain node-count stand-ins — the contrast is deliberate and labeled by hover titles). Trajectory cell indexes now count sub-calls, so `#N` totals grow on Code Mode turns. Specs pin the interleave order and durations, the running em-dash arm, window fractions (offsets/widths), the running-lane extension, and the rendered lane under the turn row. +The waterfall carries the first REAL wall-time rendering in the client (turn bars remain node-count stand-ins — the contrast is deliberate and labeled by hover titles). Trajectory cell indexes now count sub-calls, so `#N` totals grow on Code Mode turns. Specs pin the interleave order and durations, the running em-dash arm, window fractions (offsets/widths), the running-lane extension, the unknown-timing (settle-only) lane, and the rendered lane under the turn row; the built-client Code Mode fixture snapshot additionally pins both tabs' assembled rendering (sub-cells with real +0.8s durations, measured lanes). diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md index fbfb26c3a6..aaae06b1fc 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md @@ -15,7 +15,7 @@ trajectory 过去仍把一个 `run_code` 轮次渲染为单个不透明的 Tool **trajectory:`subtool` 单元格穿插在其父 Tool 单元格之后。waterfall:所属轮次行之下、带真实计时的子泳道(sub-lane)。** - **trajectory**:布局 fold 接收快照的 `codeDispatches` 索引;凡某个 Tool 单元格的 `callId` 名下存在分发(assistant 块内的调用、孤儿结果与运行中的调用一视同仁),fold 就在该单元格之后按启动顺序为每个子分发穿插一个 `subtool` 单元格,索引在整个穿插序列中保持连续编号。已结算子调用的耗时来自其 start/settle 事件对(`durationSeconds(sub.time, sub.callTime)`);运行中的子调用则显示破折号,与原生的进行中约定完全一致。新增的单元格类型带有 `Sub` 标签(business 色调)与 28px 缩进,嵌套关系一眼可辨。 -- **waterfall**:`deriveSubSpans` 把分发索引折叠成带真实计时的逐轮次泳道:每个父调用的分发窗口为首个 start → 最后一个 settle,每条泳道的偏移/宽度即其在该窗口中的占比,因此并行的子调用(PR3)会肉眼可见地重叠。运行中的泳道以较低的不透明度延伸至窗口末端,耗时为 null。泳道绘制在所属轮次的条形行之下,并缩放进固定的泳道预算。 +- **waterfall**:`deriveSubSpans` 把分发索引折叠成带真实计时的逐轮次泳道:每个父调用的分发窗口为首个 start → 最后一个 settle,每条泳道的偏移/宽度即其在该窗口中的占比,因此并行的子调用(PR3)会肉眼可见地重叠。每条泳道带有 `timing` 来源标记:`measured`(观察到了成对事件)、`running`(settle 未到 — 以较低不透明度延伸至窗口末端)或 `unknown`(回放窗口只含 settle、`callTime: null` — 画成空心并以「duration unknown」为悬停标题,绝不伪造 0 ms)。泳道绘制在所属轮次的条形行之下,并缩放进固定的泳道预算。 - 两个视图都经由标准的快照 hook 读取 `codeDispatches`:没有新的 wire 数据,也没有新的 store;回放的渲染由构造保证与实时完全一致。 ## 曾考虑的替代方案 @@ -28,4 +28,4 @@ trajectory 过去仍把一个 `run_code` 轮次渲染为单个不透明的 Tool ## 后果 -waterfall 承载了 client 中第一处真实的墙钟时间渲染(轮次条仍是节点计数的占位;这一反差是有意为之,并由悬停标题标注)。trajectory 的单元格索引现在会把子调用计入,因此 Code Mode 轮次上的 `#N` 总数会随之增大。spec 锁定穿插顺序与耗时、运行中的破折号分支、窗口占比(偏移/宽度)、运行中泳道的延伸,以及轮次行之下实际渲染出的泳道。 +waterfall 承载了 client 中第一处真实的墙钟时间渲染(轮次条仍是节点计数的占位;这一反差是有意为之,并由悬停标题标注)。trajectory 的单元格索引现在会把子调用计入,因此 Code Mode 轮次上的 `#N` 总数会随之增大。spec 锁定穿插顺序与耗时、运行中的破折号分支、窗口占比(偏移/宽度)、运行中泳道的延伸、unknown 计时(仅 settle)泳道,以及轮次行之下实际渲染出的泳道;构建产物级的 Code Mode fixture 快照另行锁定两个标签页的组装后渲染(带真实 +0.8s 耗时的子单元格、measured 泳道)。 diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index 5abf8bc6c0..e042e857c0 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -5,7 +5,8 @@ // the code-variant parent row titled by the model-authored description, its // three always-visible nested sub-rows (bash through the sample registration, // read through GenericToolCard, the failing read wearing the error state), -// the expanded program body, and details-panel resolution of a sub-callId. +// the expanded program body, details-panel resolution of a sub-callId, and +// the trajectory/waterfall tabs' sub-call cells and timing lanes. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -177,3 +178,64 @@ it('expands the code row into the program body and resolves a sub-row through th } `) }) + +it('trajectory and waterfall surface the run_code sub-calls with real timing', async () => { + boot() + await openFixtureSession() + + // Switch to the trajectory tab (same slot ring the chat view registers in). + fireEvent.click(await screen.findByRole('tab', { name: 'Trajectory' })) + await waitFor(() => { + expect(document.querySelector('[data-kind="subtool"]')).not.toBeNull() + }, { timeout: 10_000 }) + const subCells = [...document.querySelectorAll('[data-kind="subtool"]')] + expect({ + // Three Sub cells nested under the run_code Tool cell, in dispatch order, + // each with a real +N.Ns own-duration off the start/settle pair (the + // fixture spaces every event 800ms apart — never the em dash). + subCells: subCells.map(cell => visibleText(cell)), + }).toMatchInlineSnapshot(` + { + "subCells": [ + "#53Subbash · {"command":"ls notes","description":"List notes"}+0.8s", + "#54Subread · {"path":"notes/demo.txt"}+0.8s", + "#55Subread · {"path":"notes/missing.txt"}+0.8s", + ], + } + `) + + // Waterfall: each sub-call draws a measured lane scaled into the parent + // turn's dispatch window. + fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' })) + await waitFor(() => { + expect(document.querySelector('[data-subspan]')).not.toBeNull() + }, { timeout: 10_000 }) + const lanes = [...document.querySelectorAll('[data-subspan]')] + expect({ + lanes: lanes.map(lane => ({ + label: visibleText(lane.querySelector('[class*="subTag"]') ?? lane), + title: lane.querySelector('[data-timing]')?.getAttribute('title'), + timing: lane.querySelector('[data-timing]')?.getAttribute('data-timing'), + })), + }).toMatchInlineSnapshot(` + { + "lanes": [ + { + "label": "bash", + "timing": "measured", + "title": "bash · 0.80s", + }, + { + "label": "read", + "timing": "measured", + "title": "read · 0.80s", + }, + { + "label": "read", + "timing": "measured", + "title": "read · 0.80s", + }, + ], + } + `) +}) diff --git a/packages/client/ui-trajectory/src/client/WaterfallView.tsx b/packages/client/ui-trajectory/src/client/WaterfallView.tsx index 09f26a9425..ad81845fb9 100644 --- a/packages/client/ui-trajectory/src/client/WaterfallView.tsx +++ b/packages/client/ui-trajectory/src/client/WaterfallView.tsx @@ -55,12 +55,15 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa {lane.name} ))} diff --git a/packages/client/ui-trajectory/src/client/spans.ts b/packages/client/ui-trajectory/src/client/spans.ts index d7c86faa9d..585a336333 100644 --- a/packages/client/ui-trajectory/src/client/spans.ts +++ b/packages/client/ui-trajectory/src/client/spans.ts @@ -9,8 +9,14 @@ import type { ConversationNode, ConversationSnapshot } from '@deepseek-ai/dsh-cl export interface SubSpanLane { callId: string name: string - /** Wall duration in ms; null while running (start seen, settle not). */ + /** Wall duration in ms; null unless both endpoints were observed (`timing: 'measured'`). */ durationMs: number | null + /** + * Timing provenance: `measured` = start/settle pair observed; `running` = + * start seen, settle pending; `unknown` = settle-only replay window (the + * start fell outside), so no duration claim is possible. + */ + timing: 'measured' | 'running' | 'unknown' /** Start offset as a fraction of the parent turn's dispatch window [0, 1). */ offsetFraction: number /** Width as a fraction of the window (running lanes extend to the window end). */ @@ -106,6 +112,9 @@ export function deriveSubSpans( for (const [parent, subs] of codeDispatches) { if (subs.length === 0) continue const turn = turnByCall.get(parent) ?? currentTurn + // A settle-only entry (callTime null: its start fell outside the replay + // window) anchors the window by its settle time — a real observation — + // but must never masquerade as a measured zero-duration span. const starts: number[] = [] const ends: number[] = [] for (const sub of subs) { @@ -119,12 +128,14 @@ export function deriveSubSpans( const windowSpan = windowEnd - windowStart const lanes: SubSpanLane[] = subs.map((sub, i) => { const settled = 'kind' in sub + const timing = settled ? (sub.callTime === null ? 'unknown' as const : 'measured' as const) : 'running' as const const start = starts[i] ?? windowStart const end = settled ? sub.time : windowEnd return { callId: sub.callId, name: settled ? sub.call?.name ?? sub.callId : sub.name, - durationMs: settled ? Math.max(0, sub.time - start) : null, + durationMs: timing === 'measured' ? Math.max(0, end - start) : null, + timing, offsetFraction: (start - windowStart) / windowSpan, widthFraction: Math.max((end - start) / windowSpan, 0.02), } diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 920478b1f0..16a853c441 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -71,6 +71,12 @@ background: var(--dsw-alias-state-business-primary); } -.barSub[data-running] { +.barSub[data-timing='running'] { opacity: 0.45; } + +/* Settle-only replay entries: no measured span — hollow, not a solid bar. */ +.barSub[data-timing='unknown'] { + background: transparent; + border: 1px dashed var(--dsw-alias-state-business-primary); +} diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 8559991889..485db395eb 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -288,7 +288,7 @@ describe('deriveSubSpans (waterfall lanes)', () => { const turn3 = lanes.get(3) expect(turn3).toHaveLength(2) // Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0. - expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, offsetFraction: 0 }) + expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, timing: 'measured', offsetFraction: 0 }) expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4) expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 }) expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4) @@ -305,11 +305,23 @@ describe('deriveSubSpans (waterfall lanes)', () => { ]]]) as unknown as ConversationSnapshot['codeDispatches'] const lanes = deriveSubSpans(dispatchNodes, codeDispatches) const running = lanes.get(3)?.find((lane) => lane.name === 'grep') - expect(running).toMatchObject({ durationMs: null }) + expect(running).toMatchObject({ durationMs: null, timing: 'running' }) // Extends from its start to the window end. expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1) }) + it('a settle-only entry (null callTime) is unknown timing, never a measured 0 ms', () => { + const codeDispatches = new Map([['p1', [ + { + kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1', + call: { name: 'bash', argsRaw: '{}' }, callTime: null, + content: [], isError: false, callView: null, resultView: null, + }, + ]]]) as unknown as ConversationSnapshot['codeDispatches'] + const lane = deriveSubSpans(dispatchNodes, codeDispatches).get(3)?.[0] + expect(lane).toMatchObject({ durationMs: null, timing: 'unknown' }) + }) + it('waterfall renders sub-span lanes under the owning turn row', () => { const codeDispatches = new Map([['p1', [ { @@ -333,5 +345,30 @@ describe('deriveSubSpans (waterfall lanes)', () => { expect(lane).not.toBeNull() expect(lane!.textContent).toContain('bash') expect(lane!.querySelector('[title*="1.80s"]')).not.toBeNull() + expect(lane!.querySelector('[data-timing="measured"]')).not.toBeNull() + }) + + it('waterfall labels a settle-only lane as duration unknown', () => { + const codeDispatches = new Map([['p1', [ + { + kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1', + call: { name: 'read', argsRaw: '{}' }, callTime: null, + content: [], isError: false, callView: null, resultView: null, + }, + ]]]) as unknown as ConversationSnapshot['codeDispatches'] + const store = createSnapshotStore({ + nodes: dispatchNodes, partial: null, + runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches, + }) + const props = { + sessionId: SID, + useSession: bindSnapshotSelector(store) as unknown as UseSession, + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + } as unknown as ConvViewProps + const view = render(createElement(WaterfallView as FC, props)) + const bar = view.container.querySelector('[data-timing="unknown"]') + expect(bar).not.toBeNull() + expect(bar!.getAttribute('title')).toContain('duration unknown') }) })