From 1d4a6149cc6c7a3fafd66a5696744f6ccf3a8e48 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 28 Jul 2026 16:28:57 +0800 Subject: [PATCH] feat(client): add trajectory timing overview --- ...-27-trajectory-inspection-ledger.i18n.yaml | 4 +- ...2026-07-27-trajectory-inspection-ledger.md | 6 +- ...6-07-27-trajectory-inspection-ledger.zh.md | 6 +- apps/web/tests/code-mode-fixture.snapshot.ts | 40 +-- apps/web/tests/navigation-panes.e2e.ts | 60 +++-- apps/web/tests/smoke-real.e2e.ts | 6 +- .../snapshots/code-mode-round/ui.expected.md | 1 - .../cordis-tool-round/ui.expected.md | 1 - .../snapshots/fresh-round-trip/ui.expected.md | 1 - .../lifecycle-chrome/reloaded.expected.md | 1 - .../live-interactions/cancel.expected.md | 1 - .../live-interactions/error-auth.expected.md | 1 - .../live-interactions/retry.expected.md | 1 - .../navigation-panes/trajectory.expected.md | 1 + .../navigation-panes/waterfall.expected.md | 1 - .../question-composer/answered.expected.md | 1 - .../snapshots/seeded-history/ui.expected.md | 1 - .../snapshots/steering/mid-steer.expected.md | 1 - .../snapshots/steering/settled.expected.md | 1 - .../src/client/sessions/request-inspection.ts | 5 +- .../runtime/tests/request-inspection.spec.ts | 21 ++ .../src/client/contract/slots.ts | 4 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 4 +- packages/client/ui-trajectory/README.zh.md | 4 +- packages/client/ui-trajectory/package.json | 2 +- .../client/TrajectoryStatsHeader.module.css | 7 - .../src/client/TrajectoryStatsHeader.tsx | 21 -- .../src/client/TrajectoryTimeline.module.css | 181 +++++++++++++ .../src/client/TrajectoryTimeline.tsx | 216 ++++++++++++++++ .../src/client/TrajectoryView.tsx | 34 ++- .../src/client/WaterfallView.tsx | 75 ------ .../client/ui-trajectory/src/client/index.ts | 12 +- .../client/ui-trajectory/src/client/spans.ts | 147 ----------- .../ui-trajectory/src/client/timeline.ts | 110 ++++++++ .../ui-trajectory/src/client/views.module.css | 77 +----- .../client/ui-trajectory/src/invariant.ts | 4 +- .../ui-trajectory/tests/client-bundle.spec.ts | 6 +- .../client/ui-trajectory/tests/views.spec.tsx | 239 ++++++------------ 39 files changed, 716 insertions(+), 592 deletions(-) delete mode 100644 apps/web/tests/snapshots/navigation-panes/waterfall.expected.md delete mode 100644 packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.module.css delete mode 100644 packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx delete mode 100644 packages/client/ui-trajectory/src/client/WaterfallView.tsx delete mode 100644 packages/client/ui-trajectory/src/client/spans.ts create mode 100644 packages/client/ui-trajectory/src/client/timeline.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index 3f2e13a104..b77917e96b 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.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/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: 9994eae4e6e9267a998daa05a631f35f58f28304 -2026-07-27-trajectory-inspection-ledger.zh.md: e6d64f7a010e68a0ba7c08ff29c6c23c2cbdd9b1 +2026-07-27-trajectory-inspection-ledger.md: 380dca2208b6a28214ea13779b151e4ba7dde8f5 +2026-07-27-trajectory-inspection-ledger.zh.md: be6e4328cf27666226cf1047313436b1b9571895 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index 9994eae4e6..380dca2208 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -18,8 +18,10 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested - Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus. - Session exposes a separate read-only history source containing raw log entries and paging state; the ordinary conversation snapshot remains the folded Chat projection. Trajectory subscribes to that source, exhausts its paging only while mounted, and lazily derives its event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer. - Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Complete history makes global Request numbering and cumulative usage session-wide rather than tail-window-relative. +- Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. +- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. - This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. ## Alternatives considered @@ -32,8 +34,10 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. +**Keep timing in a separate Waterfall tab.** Rejected: the placeholder summarized node counts rather than record timing and forced users to switch away from the rows they wanted to focus. A full-domain Overview keeps timing and filtered records in one visual context. + **Change global theme tokens to match the reference.** Rejected: the existing theme already provides paired light and dark semantic layers, and a local redesign does not justify changing unrelated surfaces. ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. Focused component tests pin projection, folding, selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger and inspector through the real client composition. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin projection, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index e6d64f7a01..be6e4328cf 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -18,8 +18,10 @@ Status: implemented - 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 - Session 提供独立的只读历史数据源,其中包含原始日志条目和分页状态;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 订阅该数据源,仅在挂载期间补齐全部历史,并按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。 - 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。完整历史使全局请求编号和累计用量以整个会话为范围,而不是相对于末尾窗口。 +- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。 - 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 +- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 - 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 ## 曾考虑的替代方案 @@ -32,8 +34,10 @@ Status: implemented **复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 +**将计时保留在独立的 waterfall 标签页中。** 不予采纳:占位实现汇总的是节点数而非记录计时,并迫使用户离开想要聚焦的记录。保留完整时间范围的 Overview 区域让计时和筛选后的记录处于同一视觉上下文中。 + **修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。 ## 后果 -轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。针对性组件测试锁定投影、折叠、选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表与检查器。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间与耗时数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定投影、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 区域与检查器。 diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index d90a60641c..eff82cc0aa 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -6,7 +6,7 @@ // 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, details-panel resolution of a sub-callId, and -// the trajectory/waterfall tabs' sub-call cells and timing lanes. +// the Trajectory tab's sub-call cells and timing overview. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -195,7 +195,7 @@ 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 () => { +it('trajectory surfaces run_code sub-calls in the ledger and timing overview', async () => { boot() await openFixtureSession() @@ -219,37 +219,17 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a } `) - // 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]')] + const timelineSubCalls = [...document.querySelectorAll('[data-timeline-span="subtool"]')] 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'), - })), + count: timelineSubCalls.length, + measured: timelineSubCalls.map(span => span.getAttribute('title')?.endsWith(' · 800 ms')), }).toMatchInlineSnapshot(` { - "lanes": [ - { - "label": "bash", - "timing": "measured", - "title": "bash · 0.80 s", - }, - { - "label": "read", - "timing": "measured", - "title": "read · 0.80 s", - }, - { - "label": "read", - "timing": "measured", - "title": "read · 0.80 s", - }, + "count": 3, + "measured": [ + true, + true, + true, ], } `) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 311122780b..bcd0f1dc5f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -1,12 +1,12 @@ -// Web e2e scenarios: navigation & panes — the view tabs (Trajectory / -// Waterfall), the details column, and sidebar search, all over ONE rich +// Web e2e scenarios: navigation & panes — the Trajectory view and timing +// overview, the details column, and sidebar search, all over ONE rich // two-turn seeded fixture rendered purely from the log (the seeded-history // pattern: zero model calls in replay, so every surface here is the client // fold + host history RPC, not replay binding). The seed is recorded live // under the standard discipline: turn 1 produces a bash call plus two // parallel reads in one assistant message (tool-call density for the -// trajectory/waterfall lanes and a details-capable bash row), turn 2 a -// markdown-rich reply (a second turn so the waterfall has two lanes). +// trajectory ledger/timing lanes and a details-capable bash row), turn 2 a +// markdown-rich reply. import { mkdir, readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -24,7 +24,6 @@ import { saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') -const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md') const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' @@ -40,6 +39,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { let browser: Browser let page: Page let tripwire: ReturnType + let slotErrors: string[] beforeAll(async () => { scaffold = await launchWebScaffold({}) @@ -59,6 +59,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) tripwire = watchConsole(page) + slotErrors = [] + page.on('console', (message) => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) { + slotErrors.push(message.text()) + } + }) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) @@ -127,6 +133,16 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) await page.getByRole('tab', { name: 'Trajectory' }).click() + await page.waitForTimeout(100) + expect({ + pageErrors: tripwire.pageErrors, + slotErrors, + warnings: tripwire.warnings, + }).toEqual({ + pageErrors: [], + slotErrors: [], + warnings: [], + }) // Turn rules partition the ledger without restoring a separate header row. await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2) await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0) @@ -141,22 +157,21 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { .getByRole('button', { name: 'Close details' }).click() }, 60_000) - it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-waterfall')) - await page.getByRole('tab', { name: 'Waterfall' }).click() - // The stats header rides the waterfall body. The span fold counts THREE - // spans for this two-turn log: only assistant/steering nodes carry a turn - // number, so the first user message lands in a turn-0 prologue span (a - // P-I placeholder shape — pinned as-is; real spans are deferred to - // P-III per the view's deviation ledger). Calls: bash + two reads. - await expect.poll(() => page.getByText(/3 turns · \d+ steps · 3 tool calls/).count(), { timeout: 15_000 }).toBe(1) - // One lane per span, tagged by turn number, prologue included. - for (const tag of ['turn 0', 'turn 1', 'turn 2']) { - await expect.poll(() => page.getByText(tag, { exact: true }).count(), { timeout: 10_000 }).toBe(1) - } - const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) - .split(SEED_ID).join('{{seededId}}') - await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE) + it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) + const plot = page.getByLabel('Timeline overview; drag horizontally to filter events') + const before = await page.locator('tr[data-kind]').count() + const box = await plot.boundingBox() + if (box === null) throw new Error('trajectory timeline plot has no layout box') + await page.mouse.move(box.x + box.width * 0.55, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2) + await page.mouse.up() + await page.getByRole('button', { name: 'Clear selection' }).waitFor() + await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }) + .toBeLessThan(before) + await page.getByRole('button', { name: 'Clear selection' }).click() + await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before) }, 60_000) it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => { @@ -186,9 +201,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.pageErrors).toEqual([]) + expect(slotErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md', + 'seed.jsonl', 'trajectory.expected.md', 'details-open.expected.md', ]) }) }) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 58db1beafd..77fc475fae 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -438,12 +438,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await screen(page, '04-round-complete') }, 150_000) - it('4 view tabs: Chat / Trajectory / Waterfall all switch', async () => { + it('view tabs: Chat and Trajectory switch', async () => { onTestFailed(() => saveFailureShot(page, 'w5-tabs')) await page.locator('button', { hasText: /Trajectory/i }).first().click() await screen(page, '05-trajectory-tab') - await page.locator('button', { hasText: /Waterfall/i }).first().click() - await screen(page, '06-waterfall-tab') + await page.getByLabel('Trajectory timeline').waitFor() + await expect.poll(() => page.getByRole('tab', { name: 'Waterfall' }).count()).toBe(0) await page.locator('button', { hasText: /^Chat$/i }).first().click() await screen(page, '07-back-to-chat') }) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index ea364a44db..c79f9dd40f 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -5,7 +5,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop." - 'button "Think The user wants me to write a single `run_code` program that:"': - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index e2a1275f6c..9ac738891d 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -5,7 +5,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop." - button "复制": - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index dc572023fd..0be9844393 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -5,7 +5,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop." - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 056bd71144..443ef13aaa 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -5,7 +5,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: Reply with the single word LIGHTHOUSE and stop. - button "Think The user wants me to reply with a single word. Let me comply.": - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 972062a3c5..01616214fa 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -5,7 +5,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: Reply with a one-sentence description of event sourcing, then stop. - paragraph: partial - text: 已停止 0 tokens · 1 turns · 1 steps diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 64bdb9c39b..4ee5f6d155 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -5,7 +5,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: Reply with a one-sentence description of event sourcing, then stop. - textbox "Message the agent" - button "Add attachment": diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 9d41e87f41..31b586810e 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -5,7 +5,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: Reply with a one-sentence description of event sourcing, then stop. - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 94a7690d48..205e711c19 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -2,6 +2,7 @@ - text: Trajectory - button "Collapse calls" - button "Collapse turns" +- region "Trajectory timeline": Overview 9 timed events - table: - rowgroup: - row "SYSTEM, Initial System Prompt": diff --git a/apps/web/tests/snapshots/navigation-panes/waterfall.expected.md b/apps/web/tests/snapshots/navigation-panes/waterfall.expected.md deleted file mode 100644 index 6c5ab1a046..0000000000 --- a/apps/web/tests/snapshots/navigation-panes/waterfall.expected.md +++ /dev/null @@ -1 +0,0 @@ -- text: 3 turns · 3 steps · 3 tool calls turn 0 turn 1 turn 2 diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 94179416a4..4bd8985405 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -5,7 +5,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop." - button "复制": - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 4f9181f702..3791bb2ee4 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -5,7 +5,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop." - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index a26bbb7bd8..981ef272da 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -5,7 +5,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index a5735a09d5..e32735c64b 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -5,7 +5,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" - - tab "Waterfall" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/runtime/src/client/sessions/request-inspection.ts index 98eacc8e80..6928a10705 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/runtime/src/client/sessions/request-inspection.ts @@ -150,8 +150,11 @@ function deriveCallSchemas( } for (const event of events) { if (event.type === 'request/header') { + const tools: unknown = event.data.header.tools active = new Map( - (event.data.header.tools ?? []).map(schema => [schema.name, schema]), + Array.isArray(tools) + ? (tools as ToolSchema[]).map(schema => [schema.name, schema]) + : [], ) continue } diff --git a/packages/client/runtime/tests/request-inspection.spec.ts b/packages/client/runtime/tests/request-inspection.spec.ts index 42fb3844ce..ac80eabd68 100644 --- a/packages/client/runtime/tests/request-inspection.spec.ts +++ b/packages/client/runtime/tests/request-inspection.spec.ts @@ -105,4 +105,25 @@ describe('inspectRequests', () => { expect(snapshot.callSchemas.get('nested')?.name).toBe('read') }) + + it('treats a scrubbed durable-fixture tool catalog as unavailable', () => { + const snapshot = inspectRequests(entriesOf([ + at(0, 'request/header', { + reason: 'initial', + header: { + config: { provider: 'fake', model: 'model' }, + tools: '{{tools}}', + }, + }), + at(1, 'tool/call', { + turn: 1, + step: 1, + callId: 'call-1', + name: 'read', + arguments: '{}', + }), + ])) + + expect(snapshot.callSchemas).toEqual(new Map()) + }) }) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index f16d6ebda6..1f19f4cc3e 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -19,7 +19,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps } /** * The conversation view ring: one list entry per view tab (chat here; - * trajectory/waterfall from ui-trajectory), rendered one-at-a-time by + * Trajectory from ui-trajectory), rendered one-at-a-time by * ConversationRoot via `only: `. Declared by this package's * 'conversation' entry (declaring is claiming). Session scope: views read * the conversation snapshot through the standard kit. @@ -133,7 +133,7 @@ export interface ConvViewOwnerProps {} /** * Owner share of a per-view toolview slot: the call material the rendering - * view supplies per row. Uniform across views — the trajectory/waterfall + * view supplies per row. Uniform across views — the Trajectory * toolview slots (same kind/scope/owner, names fixed by the slot-naming * discipline) land with their own row render sites; today only the chat slot * is declared (RendersCheck rejects a declaration nobody renders). diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index aac415e1c1..cf8daf65a5 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/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: 884db29b746124ecc1d7a8d2312101b2930c113f -README.zh.md: a4d7d4513317692798bb6ea8b453293fa05b6cbe +README.md: ee6fb97894ec7503ccb9c0cb6a74c122fbcd852c +README.zh.md: 238a499f646a4bf637c037f8906be6ae684bb013 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 884db29b74..ee6fb97894 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. The package also provides the Waterfall view and remains a pure-consumer plugin (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A fixed Overview above the ledger projects real record start/duration timing from left to right; dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. ## Model Experience @@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **In-flight Time stays blank** — `partial` / `runningCalls` rows show their running state without a fabricated duration until a live clock policy lands; record selection is intentionally local to Trajectory; anchor deep-linking remains deferred. +- **In-flight Time stays blank** — `partial` / `runningCalls` rows show their running state without a fabricated duration until a live clock policy lands, so the Overview renders a start marker rather than inventing a live span; record and timeline selection are intentionally local to Trajectory; anchor deep-linking remains deferred. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index a4d7d45133..238a499f64 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。该包(package)还提供 waterfall(瀑布式事件)视图,并保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 ## 模型体验 @@ -14,4 +14,4 @@ Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助 ## 已知限制与暂缓事项 -- **进行中的 Time 保持空白**:`partial`/`runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时;记录选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。 +- **进行中的 Time 保持空白**:`partial`/`runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时,因此 Overview 区域只渲染开始标记,而不会杜撰实时跨度;记录选择与时间线选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 33c53c38b6..fc853d19d1 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", - "description": "Trajectory event-ledger and Waterfall views: pure-consumer plugin registering into the conversation ViewMap (no service)", + "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.module.css b/packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.module.css deleted file mode 100644 index 9def8790a9..0000000000 --- a/packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.module.css +++ /dev/null @@ -1,7 +0,0 @@ -.root { - padding: 4px 16px; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-secondary); - border-bottom: 1px solid var(--dsw-alias-border-l2); -} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.tsx deleted file mode 100644 index 089b56acbc..0000000000 --- a/packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// TrajectoryStatsHeader: span totals row rendered at the top of both -// placeholder view bodies (chrome dissolved into the views — the header is -// part of what these views ARE, not registration metadata). Subscribes to -// `nodes` only: chunk batches never swap that reference, so the row is quiet -// during streaming. - -import { memo, useMemo } from 'react' -import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' -import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { deriveSpans, deriveSpanStats } from './spans.ts' -import css from './TrajectoryStatsHeader.module.css' - -/** Props: the conversation-snapshot selector hook (handed down by the view body). */ -export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook } - -export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) { - const nodes = useSession(s => s.nodes) - const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes]) - if (stats.turns === 0) return null - return
{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}
-}) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css new file mode 100644 index 0000000000..26994038c1 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -0,0 +1,181 @@ +.root { + flex: none; + padding: 8px 16px 12px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-1); + user-select: none; +} + +.header { + display: flex; + align-items: center; + min-height: 24px; + gap: 8px; +} + +.title { + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xs-13); + font-weight: 600; +} + +.summary { + flex: 1; + color: var(--dsw-alias-label-caption); + font: var(--dsw-font-xs-13); +} + +.clear { + flex: none; + padding: 2px 8px; + border: 0; + border-radius: 4px; + color: var(--dsw-alias-state-business-primary); + background: transparent; + font: var(--dsw-font-xs-13); + cursor: pointer; +} + +.clear:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.clear:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 1px; +} + +.plot { + position: relative; + height: 72px; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 4px; + background: var(--dsw-alias-bg-layer-2); + cursor: crosshair; + touch-action: none; +} + +.plot:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 1px; +} + +.ticks { + position: absolute; + inset: 0 8px auto; + height: 20px; + border-bottom: 1px solid var(--dsw-alias-border-l1); +} + +.tick { + position: absolute; + left: var(--trajectory-tick-left); + padding: 2px 4px; + transform: translateX(-50%); + white-space: nowrap; + color: var(--dsw-alias-label-caption); + font: var(--dsw-font-xs-13); +} + +.tick:first-child { + transform: none; +} + +.tick:last-child { + transform: translateX(-100%); +} + +.tick::after { + position: absolute; + top: 20px; + bottom: -52px; + left: 50%; + width: 1px; + background: var(--dsw-alias-border-l1); + content: ''; +} + +.lanes { + position: absolute; + inset: 24px 8px 6px; +} + +.span { + position: absolute; + top: calc(var(--trajectory-span-lane) * 14px); + left: var(--trajectory-span-left); + width: var(--trajectory-span-width); + height: 8px; + min-width: 2px; + border-radius: 1px; + background: var(--dsw-alias-label-tertiary); + opacity: 0.72; +} + +.span[data-timeline-span='message'], +.span[data-timeline-span='compacted'] { + background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); +} + +.span[data-timeline-span='tool'], +.span[data-timeline-span='subtool'] { + background: var(--dsw-alias-state-business-primary); +} + +.selection { + position: absolute; + top: 0; + bottom: 0; + left: var(--trajectory-selection-left); + width: var(--trajectory-selection-width); + min-width: 1px; + border-right: 1px solid var(--dsw-alias-state-business-primary); + border-left: 1px solid var(--dsw-alias-state-business-primary); + background: color-mix( + in srgb, + var(--dsw-alias-state-business-primary) 12%, + transparent + ); + box-shadow: + -100vw 0 0 100vw color-mix(in srgb, var(--dsw-alias-bg-layer-1) 58%, transparent), + 100vw 0 0 100vw color-mix(in srgb, var(--dsw-alias-bg-layer-1) 58%, transparent); + pointer-events: none; +} + +.selection::before, +.selection::after { + position: absolute; + top: 0; + bottom: 0; + width: 3px; + background: var(--dsw-alias-state-business-primary); + content: ''; +} + +.selection::before { + left: -2px; +} + +.selection::after { + right: -2px; +} + +.selection[data-dragging='true'] { + background: color-mix( + in srgb, + var(--dsw-alias-state-business-primary) 18%, + transparent + ); +} + +@media (max-width: 720px) { + .root { + padding-right: 12px; + padding-left: 12px; + } + + .plot { + height: 64px; + } +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx new file mode 100644 index 0000000000..ce6cd66a73 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -0,0 +1,216 @@ +/** Chrome-Network-style overview timeline for focusing the trajectory ledger. */ + +import { + memo, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, +} from 'react' +import type { TrajectoryTurnModel } from './layout.ts' +import { + deriveTrajectoryTimeline, + filterTrajectoryTimelineRange, + formatTimelineOffset, + type TrajectoryTimeRange, +} from './timeline.ts' +import css from './TrajectoryTimeline.module.css' + +const TICK_COUNT = 5 +const MINIMUM_DRAG_PX = 3 + +interface FractionRange { + start: number + end: number +} + +/** Props for the fixed full-domain overview above the trajectory ledger. */ +export interface TrajectoryTimelineProps { + turns: readonly TrajectoryTurnModel[] + range: TrajectoryTimeRange | null + onRangeChange: (range: TrajectoryTimeRange | null) => void +} + +function orderedRange(left: number, right: number): FractionRange { + return left <= right ? { start: left, end: right } : { start: right, end: left } +} + +function clampFraction(value: number): number { + return Math.min(1, Math.max(0, value)) +} + +function rangeFraction( + range: TrajectoryTimeRange, + start: number, + duration: number, +): FractionRange { + return orderedRange( + clampFraction((range.start - start) / duration), + clampFraction((range.end - start) / duration), + ) +} + +/** Overview renderer with drag-to-filter and Escape/clear reset. */ +export const TrajectoryTimeline = memo(function TrajectoryTimeline({ + turns, + range, + onRangeChange, +}: TrajectoryTimelineProps) { + const model = useMemo(() => deriveTrajectoryTimeline(turns), [turns]) + const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null) + const [draft, setDraft] = useState(null) + const domainDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0)) + const committed = model === null || range === null + ? null + : rangeFraction(range, model.start, domainDuration) + const visibleRange = draft ?? committed + const focusedCount = useMemo( + () => range === null + ? model?.spans.length ?? 0 + : deriveTrajectoryTimeline(filterTrajectoryTimelineRange(turns, range))?.spans.length ?? 0, + [model?.spans.length, range, turns], + ) + + if (model === null) { + return ( +
+
+ Overview + No timing data +
+
+ ) + } + + const fractionAt = (event: PointerEvent): number => { + const rect = event.currentTarget.getBoundingClientRect() + return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) + } + + const commit = (fraction: FractionRange) => { + onRangeChange({ + start: model.start + fraction.start * domainDuration, + end: model.start + fraction.end * domainDuration, + }) + } + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return + const rect = event.currentTarget.getBoundingClientRect() + const anchor = fractionAt(event) + dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) } + if (typeof event.currentTarget.setPointerCapture === 'function') { + event.currentTarget.setPointerCapture(event.pointerId) + } + setDraft({ start: anchor, end: anchor }) + } + + const onPointerMove = (event: PointerEvent) => { + const drag = dragRef.current + if (drag === null || drag.pointerId !== event.pointerId) return + setDraft(orderedRange(drag.anchor, fractionAt(event))) + } + + const onPointerEnd = (event: PointerEvent) => { + const drag = dragRef.current + if (drag === null || drag.pointerId !== event.pointerId) return + const selected = orderedRange(drag.anchor, fractionAt(event)) + dragRef.current = null + setDraft(null) + if ((selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX) { + onRangeChange(null) + } else { + commit(selected) + } + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || range === null) return + event.preventDefault() + onRangeChange(null) + } + + const onPointerCancel = () => { + dragRef.current = null + setDraft(null) + } + + const ticks = Array.from({ length: TICK_COUNT }, (_, index) => { + const fraction = index / (TICK_COUNT - 1) + return { + fraction, + label: formatTimelineOffset(fraction * domainDuration), + } + }) + const summary = range === null + ? `${model.spans.length} timed events` + : `${focusedCount} of ${model.spans.length} events · ${formatTimelineOffset(range.start - model.start)}–${formatTimelineOffset(range.end - model.start)}` + + return ( +
+
+ Overview + {summary} + {range !== null && ( + + )} +
+
+ + + {visibleRange !== null && ( + +
+ ) +}) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index e2b9d51228..645a163749 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -14,7 +14,11 @@ import { type TrajectoryUsage, } from './TrajectoryTable.tsx' import { TrajectoryToolbar } from './TrajectoryToolbar.tsx' +import { TrajectoryTimeline } from './TrajectoryTimeline.tsx' import { deriveTrajectoryLayout } from './layout.ts' +import { + filterTrajectoryTimelineRange, type TrajectoryTimeRange, +} from './timeline.ts' import css from './views.module.css' const EMPTY_IDS: ReadonlySet = new Set() @@ -73,6 +77,10 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_IDS) const [collapsedAssistants, setCollapsedAssistants] = useState>(EMPTY_IDS) + const [timelineSelection, setTimelineSelection] = useState<{ + branchId: number + range: TrajectoryTimeRange + } | null>(null) const nodes = useSession(s => s.nodes) const inspection = useSession(s => s.inspection) const hasMore = useSession(s => s.hasMore) @@ -249,8 +257,15 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches, ], ) + const timelineRange = timelineSelection?.branchId === currentBranch.id + ? timelineSelection.range + : null + const focusedTurns = useMemo( + () => filterTrajectoryTimelineRange(turns, timelineRange), + [timelineRange, turns], + ) const collapsibleTurnIds = useMemo( - () => turns + () => focusedTurns .filter(turn => turn.groups.reduce( (count, group) => @@ -259,13 +274,13 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T 0, ) > 1) .map(turn => turn.turn), - [turns], + [focusedTurns], ) const allTurnsCollapsed = collapsibleTurnIds.length > 0 && collapsibleTurnIds.every(turn => collapsedTurns.has(turn)) const collapsibleAssistantIds = useMemo(() => { const ids: number[] = [] - for (const turn of turns) { + for (const turn of focusedTurns) { const cells = turn.groups.flatMap(group => group.cells) for (let i = 0; i < cells.length; i++) { const cell = cells[i] @@ -275,7 +290,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T } } return ids - }, [turns]) + }, [focusedTurns]) const allAssistantsCollapsed = collapsibleAssistantIds.length > 0 && collapsibleAssistantIds.every(index => collapsedAssistants.has(index)) @@ -331,11 +346,18 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T allAssistantsCollapsed={allAssistantsCollapsed} onToggleAllAssistants={toggleAllAssistants} /> + { + setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range }) + }} + />
s.nodes) - const codeDispatches = useSession(s => s.codeDispatches) - const spans = useMemo(() => deriveSpans(nodes), [nodes]) - const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches]) - if (spans.length === 0) return

No timing data

- return ( - <> - -
- {spans.map((span, i) => ( -
-
- turn {span.turn} - - {span.calls > 0 && ( - - )} -
- {(subSpans.get(span.turn) ?? []).map(lane => ( -
- {lane.name} - -
- ))} -
- ))} -
- - ) -} diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 24d697db68..50d25f4df4 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -1,6 +1,6 @@ /** - * Browser trajectory plugin contributing two entries to the conversation - * view slot without defining a service. + * Browser trajectory plugin contributing one entry to the conversation view + * slot without defining a service. */ import type { Context } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -8,7 +8,6 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' // owning package) must be in the program for the register calls to type. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' -import { WaterfallView } from './WaterfallView.tsx' /** * Required services (cordis fiber inject). 'conversation' is an ordering @@ -20,9 +19,8 @@ import { WaterfallView } from './WaterfallView.tsx' export const inject = ['slots', 'conversation', 'sessions'] /** - * Client plugin body: register the trajectory and waterfall view tabs. The - * registrations ride the slot service's effect wrapper (plugin unload - * removes both tabs). + * Client plugin body: register the trajectory view tab. The registration + * rides the slot service's effect wrapper, so plugin unload removes the tab. * @param ctx - client root context. */ export function apply(ctx: Context): void { @@ -39,6 +37,4 @@ export function apply(ctx: Context): void { return { loadAllHistory: () => session.loadAllHistory() } }, }, TrajectoryView) - ctx.slots.register( - { name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView) } diff --git a/packages/client/ui-trajectory/src/client/spans.ts b/packages/client/ui-trajectory/src/client/spans.ts deleted file mode 100644 index 585a336333..0000000000 --- a/packages/client/ui-trajectory/src/client/spans.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Rough per-turn span derivation shared by the two placeholder views and the - * header stats bar. P-I ships no timing data, so a span's weight is its node - * count, not wall time (deviation ledger #3 — real spans land in P-III). - */ -import type { ConversationNode, ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' - -/** One run_code sub-dispatch lane in the waterfall: real timing off the start/settle pair. */ -export interface SubSpanLane { - callId: string - name: string - /** 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). */ - widthFraction: number -} - -/** One turn's worth of activity, folded from the snapshot node window. */ -export interface TurnSpan { - turn: number - /** Assistant step messages inside the turn. */ - steps: number - /** Tool results inside the turn (running calls are not folded in P-I). */ - calls: number - /** Total nodes attributed to the turn (span weight stand-in). */ - nodes: number -} - -/** Aggregate totals for the header stats bar. */ -export interface SpanStats { - turns: number - steps: number - calls: number -} - -/** - * Fold snapshot nodes into per-turn spans. Only assistant nodes carry a turn - * number; user/steering/context/tool nodes attach to the turn last seen in - * sequence order (turn 0 collects the pre-assistant prologue). - * @param nodes - snapshot nodes in surface order. - * @returns spans ordered by first appearance. - */ -export function deriveSpans(nodes: ConversationSnapshot['nodes']): readonly TurnSpan[] { - const spans = new Map() - let currentTurn = 0 - const spanFor = (turn: number): TurnSpan => { - let span = spans.get(turn) - if (span === undefined) { - span = { turn, steps: 0, calls: 0, nodes: 0 } - spans.set(turn, span) - } - return span - } - for (const node of nodes) { - if (hasTurn(node)) currentTurn = node.turn - const span = spanFor(currentTurn) - span.nodes += 1 - if (node.kind === 'assistant') span.steps += 1 - if (node.kind === 'tool-result') span.calls += 1 - } - return [...spans.values()] -} - -/** - * Aggregate spans into the header totals. - * @param spans - deriveSpans product. - * @returns turn/step/call totals. - */ -export function deriveSpanStats(spans: readonly TurnSpan[]): SpanStats { - let steps = 0 - let calls = 0 - for (const span of spans) { - steps += span.steps - calls += span.calls - } - return { turns: spans.length, steps, calls } -} - -function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } { - return node.kind === 'assistant' || node.kind === 'steering' -} - -/** - * Fold the dispatch index into per-turn sub-span lanes with REAL timing: each - * lane's offset/width scale against its parent turn's dispatch window (first - * start → last settle). Running (unsettled) lanes extend to the window end - * with a null duration. - * @param nodes - snapshot nodes (locates each parent run_code call's turn). - * @param codeDispatches - the snapshot's dispatch index. - * @returns lanes keyed by turn, in start order. - */ -export function deriveSubSpans( - nodes: ConversationSnapshot['nodes'], - codeDispatches: ConversationSnapshot['codeDispatches'], -): ReadonlyMap { - const out = new Map() - if (codeDispatches.size === 0) return out - const turnByCall = new Map() - let currentTurn = 0 - for (const node of nodes) { - if (node.kind === 'assistant' || node.kind === 'steering') currentTurn = node.turn - if (node.kind === 'tool-result') turnByCall.set(node.callId, currentTurn) - } - 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) { - const settled = 'kind' in sub - const start = settled ? sub.callTime ?? sub.time : sub.time - starts.push(start) - ends.push(settled ? sub.time : start) - } - const windowStart = Math.min(...starts) - const windowEnd = Math.max(...ends, windowStart + 1) - 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: timing === 'measured' ? Math.max(0, end - start) : null, - timing, - offsetFraction: (start - windowStart) / windowSpan, - widthFraction: Math.max((end - start) / windowSpan, 0.02), - } - }) - const existing = out.get(turn) ?? [] - out.set(turn, [...existing, ...lanes]) - } - return out -} diff --git a/packages/client/ui-trajectory/src/client/timeline.ts b/packages/client/ui-trajectory/src/client/timeline.ts new file mode 100644 index 0000000000..672d07857a --- /dev/null +++ b/packages/client/ui-trajectory/src/client/timeline.ts @@ -0,0 +1,110 @@ +/** Time-domain projection and filtering for the trajectory overview. */ + +import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts' +import type { TrajectoryTurnModel } from './layout.ts' + +/** Inclusive absolute-time selection in Unix epoch milliseconds. */ +export interface TrajectoryTimeRange { + start: number + end: number +} + +/** One timed ledger record projected into the overview. */ +export interface TrajectoryTimelineSpan extends TrajectoryTimeRange { + index: number + kind: TrajectoryCellKind + label: string + lane: number +} + +/** Full-domain model used by the overview. */ +export interface TrajectoryTimelineModel extends TrajectoryTimeRange { + spans: readonly TrajectoryTimelineSpan[] +} + +function finite(value: number | null | undefined): value is number { + return value !== null && value !== undefined && Number.isFinite(value) +} + +function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null { + if (!finite(cell.startedAt)) return null + const durationMs = finite(cell.timeSeconds) + ? Math.max(0, cell.timeSeconds * 1_000) + : 0 + return { start: cell.startedAt, end: cell.startedAt + durationMs } +} + +function laneFor(kind: TrajectoryCellKind): number { + if (kind === 'tool' || kind === 'subtool') return 2 + if (kind === 'message' || kind === 'compacted') return 1 + return 0 +} + +/** + * Project every visible timed record into a stable three-lane overview. + * @param turns - Unfiltered trajectory layout. + * @returns Timeline model, or `null` when no record carries a start time. + */ +export function deriveTrajectoryTimeline( + turns: readonly TrajectoryTurnModel[], +): TrajectoryTimelineModel | null { + const spans = turns.flatMap(turn => + turn.groups.flatMap(group => + group.cells.flatMap((cell): TrajectoryTimelineSpan[] => { + if (cell.requestOnly === true) return [] + const range = cellRange(cell) + return range === null + ? [] + : [{ + ...range, + index: cell.index, + kind: cell.kind, + label: cell.text, + lane: laneFor(cell.kind), + }] + }), + ), + ) + if (spans.length === 0) return null + return { + start: Math.min(...spans.map(span => span.start)), + end: Math.max(...spans.map(span => span.end)), + spans, + } +} + +function overlaps(cell: TrajectoryCellProps, range: TrajectoryTimeRange): boolean { + const timed = cellRange(cell) + return timed !== null && timed.start <= range.end && timed.end >= range.start +} + +/** + * Keep records active at any point inside an inclusive selected interval. + * @param turns - Unfiltered trajectory layout. + * @param range - Absolute selected interval, or `null` for the full ledger. + * @returns A layout retaining original turn, group, and record identities. + */ +export function filterTrajectoryTimelineRange( + turns: readonly TrajectoryTurnModel[], + range: TrajectoryTimeRange | null, +): readonly TrajectoryTurnModel[] { + if (range === null) return turns + return turns.flatMap((turn): TrajectoryTurnModel[] => { + const groups = turn.groups.flatMap((group) => { + const cells = group.cells.filter(cell => overlaps(cell, range)) + return cells.length === 0 ? [] : [{ ...group, cells }] + }) + return groups.length === 0 ? [] : [{ ...turn, groups }] + }) +} + +/** + * Format a relative timeline offset with a compact unit. + * @param milliseconds - Non-negative relative offset. + * @returns Millisecond or second label. + */ +export function formatTimelineOffset(milliseconds: number): string { + if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms` + const seconds = milliseconds / 1_000 + return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s` +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 56afe2810e..652e55df37 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -1,4 +1,4 @@ -/* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */ +/* Full-bleed, fixed-height host for the trajectory ledger. */ .root { --dsh-trajectory-toolbar-height: 40px; @@ -13,16 +13,6 @@ background: var(--dsw-alias-bg-layer-1); } -.empty { - display: grid; - flex: 1; - margin: 0; - padding: 24px; - place-items: center; - color: var(--dsw-alias-label-tertiary); - font: var(--dsw-font-xs-13); -} - .ledger { display: flex; flex: 1; @@ -30,68 +20,3 @@ min-width: 0; overflow: hidden; } - -/* Waterfall placeholder rows (shared module). */ -.row { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 16px; -} - -.turnTag { - flex: none; - width: 64px; - color: var(--dsw-alias-label-secondary); - font: var(--dsw-font-xs-13); -} - -.bar { - height: 12px; - border-radius: 4px; - background: var(--dsw-alias-bg-skeleton); -} - -.barCalls { - background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); -} - -.meta { - color: var(--dsw-alias-label-caption); - font: var(--dsw-font-xs-13); -} - -/* run_code sub-span lanes: one row per sub-dispatch under its turn row, - offset/width scaled to the dispatch window (real wall time). A running - lane pulses via reduced opacity until its settle arrives. */ -.subRow { - display: flex; - align-items: center; - gap: 8px; - margin-top: 2px; -} - -.subTag { - flex: none; - width: 88px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--dsw-alias-label-tertiary); - font: var(--dsw-font-xs-13); -} - -.barSub { - height: 8px; - background: var(--dsw-alias-state-business-primary); -} - -.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/src/invariant.ts b/packages/client/ui-trajectory/src/invariant.ts index 8638145fbe..11e56bb058 100644 --- a/packages/client/ui-trajectory/src/invariant.ts +++ b/packages/client/ui-trajectory/src/invariant.ts @@ -16,8 +16,8 @@ export const inject = ['invariants'] /** * No runtime invariant: a pure-consumer plugin — it emits no cordis events - * and owns no mutable cross-plugin state; both view-slot registrations are - * plain effects whose disposal the slot ledger's own specs and this + * and owns no mutable cross-plugin state; its view-slot registration is a + * plain effect whose disposal the slot ledger's own specs and this * package's behavior specs observe directly. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 54397caacc..129f756aa6 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -3,7 +3,7 @@ * Real tsdown artifact shape: lib/client.js hands off through * window.__ModuleLoader__.load, resolves externals through the injected * require, returns the export surface (apply + inject), and a mounted apply - * registers both view tabs into a real SlotsService ring. Skips when dist/ is + * registers the view tab into a real SlotsService ring. Skips when dist/ is * not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`). */ import { readFileSync } from 'node:fs' @@ -63,7 +63,7 @@ describe('tsdown client artifact', () => { expect(surface.inject).toEqual(['slots', 'conversation', 'sessions']) }) - it.skipIf(code === undefined)('mounted as an object plugin, apply registers both view tabs on the real ring', async () => { + it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => { const { surface } = await loadArtifact() const ctx = new Context() const slots = new SlotsService(ctx) @@ -78,7 +78,7 @@ describe('tsdown client artifact', () => { ctx.provide('sessions', {}) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() - expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory', 'waterfall']) + expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory']) await fiber.dispose() expect(slots.entries('conversation.view')).toHaveLength(0) }) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index ef301b106e..eb12b37d8b 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -1,11 +1,11 @@ // @vitest-environment jsdom /** * View registration acceptance on the real framework stack: the plugin fiber - * registers trajectory/waterfall into a real SlotsService view ring, tabs + * registers Trajectory into a real SlotsService view ring, tabs * switch inside ConversationRoot (renderSlot share driven by the same tab * projection apply uses) without collapsing chat, trajectory renders the - * event-ledger chrome (no span stats bar), waterfall keeps in-body stats, and - * fiber disposal removes both tabs. Span derivation edge cases ride along. + * event ledger with its timing overview, and fiber disposal removes the tab. + * Timeline projection and inclusive filtering edge cases ride along. */ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -23,10 +23,13 @@ import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversa import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' -import { deriveSpans, deriveSpanStats, deriveSubSpans } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts' -import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx' +import type { TrajectoryTurnModel } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/layout.ts' import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx' -import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx' +import { + deriveTrajectoryTimeline, + filterTrajectoryTimelineRange, + formatTimelineOffset, +} from '@deepseek-ai/dsh-client-ui-trajectory/src/client/timeline.ts' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' const SID = 's1' as SessionId @@ -42,12 +45,18 @@ beforeEach(() => { /** Node fixture: user prologue, two turns, one tool result inside turn 1. */ const NODES = [ { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, - { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] }, { - kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null, + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [], + timing: { stepStartTime: 1_800, firstTokenTime: 1_900, completedTime: 2_000 }, + }, + { + kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200, content: [], isError: false, callView: null, resultView: null, }, - { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] }, + { + kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [], + timing: { stepStartTime: 3_500, firstTokenTime: 3_700, completedTime: 4_000 }, + }, ] as unknown as ConversationSnapshot['nodes'] function fakeSession(nodes: ConversationSnapshot['nodes']) { @@ -167,16 +176,15 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES } describe('plugin registration', () => { - it('registers trajectory and waterfall after chat on the ring', async () => { + it('registers trajectory after chat on the ring', async () => { const b = await bench() expect(tabsOf(b.slots)).toEqual([ { id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }, - { id: 'waterfall', label: 'Waterfall' }, ]) }) - it('fiber disposal removes both tabs and leaves chat standing', async () => { + it('fiber disposal removes the tab and leaves chat standing', async () => { const b = await bench() await b.fiber.dispose() expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat']) @@ -184,17 +192,18 @@ describe('plugin registration', () => { }) describe('tab switching in ConversationRoot', () => { - it('renders all three tabs, defaults to chat, and switches to the trajectory ledger', async () => { + it('renders two tabs, defaults to chat, and switches to the trajectory ledger', async () => { const b = await bench() const view = mount(b.slots) expect(screen.getByTestId('chat-body')).toBeTruthy() - expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall']) + expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory']) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.queryByText(/turns ·/)).toBeNull() expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) expect(screen.queryByRole('columnheader')).toBeNull() expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) @@ -220,47 +229,72 @@ describe('tab switching in ConversationRoot', () => { expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull() }) - it('waterfall renders bars and switching back to chat does not collapse it', async () => { + it('dragging the overview focuses overlapping records and clear restores the ledger', async () => { const b = await bench() mount(b.slots) - fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' })) - expect(screen.getByTitle('2 nodes')).toBeTruthy() - expect(screen.getByTitle('1 tool calls')).toBeTruthy() + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + const plot = screen.getByLabelText('Timeline overview; drag horizontally to filter events') + vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ + x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, + toJSON: () => ({}), + }) + fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 1 }) + fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 }) + fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 }) - fireEvent.click(screen.getByRole('tab', { name: 'Chat' })) - expect(screen.getByTestId('chat-body')).toBeTruthy() + expect(screen.queryByRole('row', { name: /USER/ })).toBeNull() + expect(screen.getByRole('button', { name: 'Clear selection' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Clear selection' })) + expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() }) - it('empty window: placeholder copy in the body, the stats header renders nothing', async () => { + it('empty window keeps the toolbar and reports no timing data', async () => { const b = await bench() mount(b.slots, []) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByText('No timing data')).toBeTruthy() expect(screen.queryByRole('row')).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull() }) }) -describe('span derivation', () => { - it('attributes prologue to turn 0 and follows steering turn tags', () => { - const nodes = [ - { kind: 'user', seq: 1 }, - { kind: 'steering', seq: 2, turn: 5 }, - { kind: 'user', seq: 3 }, - ] as unknown as ConversationSnapshot['nodes'] - const spans = deriveSpans(nodes) - expect(spans).toEqual([ - { turn: 0, steps: 0, calls: 0, nodes: 1 }, - { turn: 5, steps: 0, calls: 0, nodes: 2 }, - ]) - expect(deriveSpanStats(spans)).toEqual({ turns: 2, steps: 0, calls: 0 }) +describe('timeline projection', () => { + const turns = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [ + { index: 1, kind: 'message', text: 'assistant', startedAt: 1_000, timeSeconds: 1 }, + { index: 2, kind: 'tool', text: 'bash', startedAt: 2_000, timeSeconds: 1 }, + { index: 3, kind: 'user', text: 'unknown', timeSeconds: 0 }, + ], + }], + }] satisfies readonly TrajectoryTurnModel[] + + it('uses real start/duration timing and stable semantic lanes', () => { + expect(deriveTrajectoryTimeline(turns)).toEqual({ + start: 1_000, + end: 3_000, + spans: [ + { + index: 1, kind: 'message', label: 'assistant', lane: 1, start: 1_000, end: 2_000, + }, + { index: 2, kind: 'tool', label: 'bash', lane: 2, start: 2_000, end: 3_000 }, + ], + }) + expect(formatTimelineOffset(999)).toBe('999 ms') + expect(formatTimelineOffset(1_500)).toBe('1.5 s') }) - it('empty inputs produce zero stats and standalone components render their empty forms', () => { - expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 }) - const { useSession } = fakeSession([]) - const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession })) - expect(container.firstChild).toBeNull() + it('filters inclusively and drops records without known timing', () => { + const focused = filterTrajectoryTimelineRange(turns, { start: 2_000, end: 2_000 }) + expect(focused[0]?.groups[0]?.cells.map(cell => cell.index)).toEqual([1, 2]) + expect(filterTrajectoryTimelineRange(turns, null)).toBe(turns) + }) + + it('empty inputs produce no model and the standalone view reports its empty form', () => { + expect(deriveTrajectoryTimeline([])).toBeNull() render(createElement( TrajectoryView, { ...standaloneProps([]), loadAllHistory: () => Promise.resolve() }, @@ -343,135 +377,8 @@ describe('TrajectoryView branches', () => { }) }) -describe('WaterfallView standalone branches', () => { - it('empty window renders the placeholder copy', () => { - render(createElement(WaterfallView as FC, - standaloneProps([]))) - expect(screen.getByText('No timing data')).toBeTruthy() - }) - - it('a turn without tool calls renders the node bar only', () => { - const nodes = [{ kind: 'user', seq: 1 }] as unknown as ConversationSnapshot['nodes'] - render(createElement(WaterfallView as FC, standaloneProps(nodes))) - expect(screen.getByTitle('1 nodes')).toBeTruthy() - expect(screen.queryByTitle(/tool calls/)).toBeNull() - }) -}) - describe('node half', () => { it('node apply is an intentional no-op (loader-managed lifecycle only)', () => { expect(() => { nodeApply() }).not.toThrow() }) }) - -describe('deriveSubSpans (waterfall lanes)', () => { - const dispatchNodes = [ - { kind: 'assistant', seq: 2, time: 6_000, turn: 3, step: 1, blocks: [] }, - { - kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1', - call: { name: 'run_code', argsRaw: '{}' }, callTime: 6_100, - content: [], isError: false, callView: null, resultView: null, - }, - ] as unknown as ConversationSnapshot['nodes'] - - it('scales settled lanes into the dispatch window with real durations', () => { - const codeDispatches = new Map([['p1', [ - { - kind: 'tool-result', seq: 101, time: 7_000, callId: 'p1:code:1', - call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200, - content: [], isError: false, callView: null, resultView: null, - }, - { - kind: 'tool-result', seq: 102, time: 8_200, callId: 'p1:code:2', - call: { name: 'read', argsRaw: '{}' }, callTime: 7_000, - content: [], isError: false, callView: null, resultView: null, - }, - ]]]) as unknown as ConversationSnapshot['codeDispatches'] - const lanes = deriveSubSpans(dispatchNodes, codeDispatches) - 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, 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) - }) - - it('a running lane extends to the window end with a null duration', () => { - const codeDispatches = new Map([['p1', [ - { - kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1', - call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200, - content: [], isError: false, callView: null, resultView: null, - }, - { callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null }, - ]]]) 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, 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', [ - { - kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1', - call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200, - 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 lane = view.container.querySelector('[data-subspan]') - expect(lane).not.toBeNull() - expect(lane!.textContent).toContain('bash') - expect(lane!.querySelector('[title*="1.80 s"]')).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') - }) -})