diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml new file mode 100644 index 0000000000..fb39ae1301 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-trajectory-step-cell.md: edf31dcc72baa980caf0aa90fb8d5ec53d44346d +2026-07-23-trajectory-step-cell.zh.md: dbe813d48c3f3ac1c0926e45137624d758109c55 diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md new file mode 100644 index 0000000000..42d896b81a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md @@ -0,0 +1,35 @@ +# Agent Note: Trajectory step cell and turn list chrome + +Status: implemented + +English | [中文](2026-07-23-trajectory-step-cell.zh.md) + +## Problem + +The trajectory tab needs a reusable step row and turn-list chrome that can show expanded assistant blocks, own-duration times, Message token columns, and in-flight work. Without folding session event times into conversation nodes and expanding blocks into cells, the UI cannot match the product chrome. + +## Decision + +[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) owns the presentational trajectory list chrome: + +- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 38px step row with kinds User / Message / Tool (no Think, Call, or Result rows). Reasoning blocks are skipped (no block-level clock). Each `tool-call` + paired `tool-result` folds into one Tool row (`name ·` truncated args) whose Time is `result.time − callTime` when both are known. Message rows carry Input/Output/Think token columns from `assistant.usage`. Own-duration Time uses `+Ns` / `+N.1s`, or `—` when absent. Selected state draws a 2px inset `--dsw-alias-brand-primary-new-colorprimary-new-color` ring (`selected` prop) and is not wired to chat selection. +- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — sticky Turn bar paints full-bleed `ghost-active-fill`; title/columns and the Message/Step body sit in a centered `max-width: 880px` lane. Cell trailing columns share the Turn header geometry (`320 = 4×71 + 3×12`); cells use pad 20/8. +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only, and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). + +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time; Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration). + +## Alternatives considered + +**Keep a Think cell for reasoning blocks.** Rejected: a single `assistant/message.time` cannot yield Think own-duration without chunk-level clocks; omit the row rather than show `—`. + +**Keep separate Call and Result rows.** Rejected: Result had no own duration to show; one Tool row carries the call→result interval. + +**Cumulative elapsed from session/turn start.** Rejected; the Time column is each row's own duration. + +**Hang usage on the first expanded row.** Rejected; usage attaches to Message only. + +**Show in-flight tool durations via Date.now().** Deferred; in-flight Time stays `—`. + +## Consequences + +The Trajectory tab can render expanded finalized and in-flight rows with own-duration times once fold emits `time`. Behavior-shaped coverage lives in `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`. Chat selection deep-links and finer block-level clocks remain deferred. diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md new file mode 100644 index 0000000000..c6bcde7deb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Trajectory 步骤单元格与轮次列表 chrome + +Status: implemented + +[English](2026-07-23-trajectory-step-cell.md) | 中文 + +## Problem + +trajectory 标签页需要可复用的步骤行与轮次列表 chrome,以展示展开后的 assistant 块、自身耗时、Message token 列,以及进行中的工作。若不将会话事件时间折叠进会话节点,并将块展开为单元格,UI 就无法对齐产品 chrome。 + +## Decision + +[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) 拥有展示型 trajectory 列表 chrome: + +- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 高 38px 的步骤行,类型为 User / Message / Tool(无 Think、Call、Result 行)。reasoning 块跳过(无块级时钟)。每对 `tool-call` + `tool-result` 折成一行 Tool(`name ·` 加截断参数),Time 在两端皆知时为 `result.time − callTime`。Message 行携带来自 `assistant.usage` 的 Input/Output/Think token 列。自身耗时 Time 使用 `+Ns` / `+N.1s`,缺失时为 `—`。选中态绘制 2px 内嵌的 `--dsw-alias-brand-primary-new-colorprimary-new-color` 环(`selected` prop),且未接线到 chat 选中。 +- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — 粘性 Turn 条背景通栏铺 `ghost-active-fill`;标题/列标与 Message/Step 主体落在居中的 `max-width: 880px` 内容道。单元格右侧列与 Turn 标头共用几何(`320 = 4×71 + 3×12`);cell pad 20/8。 +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上,并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。 + +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间;Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。 + +## Alternatives considered + +**为 reasoning 块保留 Think 单元格。** 否决:单条 `assistant/message.time` 无法给出 Think 自身耗时(除非上 chunk 级时钟);与其显示 `—`,不如省略该行。 + +**保留分开的 Call 与 Result 行。** 否决:Result 没有可展示的自身耗时;一行 Tool 承载 call→result 区间。 + +**自会话/轮次起点累计耗时。** 否决;Time 列是每行自身的耗时。 + +**将用量挂在展开后的第一行。** 否决;用量仅附着于 Message。 + +**用 Date.now() 显示进行中工具的耗时。** 延后;进行中的 Time 保持为 `—`。 + +## Consequences + +一旦 fold 发出 `time`,Trajectory 标签页即可渲染带自身耗时的已定稿与进行中展开行。行为导向的覆盖位于 `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`。chat 选中深链与更细的块级时钟仍延后。 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 1e06fad70d..08b80f2a26 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -42,6 +42,8 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock { export interface UserMessageNode { kind: 'user' seq: number + /** Unix epoch ms from the source session event. */ + time: number content: readonly ContentBlock[] source: unknown } @@ -50,6 +52,8 @@ export interface UserMessageNode { export interface AssistantMessageNode { kind: 'assistant' seq: number + /** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */ + time: number turn: number step: number blocks: readonly AssistantBlock[] @@ -63,6 +67,8 @@ export interface AssistantMessageNode { export interface SteeringMessageNode { kind: 'steering' seq: number + /** Unix epoch ms from the source session event. */ + time: number turn: number content: readonly ContentBlock[] source: unknown @@ -72,6 +78,8 @@ export interface SteeringMessageNode { export interface ContextMessageNode { kind: 'context' seq: number + /** Unix epoch ms from the source session event. */ + time: number content: readonly ContentBlock[] source: unknown meta?: unknown @@ -81,9 +89,13 @@ export interface ContextMessageNode { export interface ToolResultNode { kind: 'tool-result' seq: number + /** Unix epoch ms from the tool/result session event. */ + time: number callId: string /** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */ call: { name: string; argsRaw: string } | null + /** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */ + callTime: number | null content: readonly ContentBlock[] isError: boolean error?: { name: string; code: string } @@ -98,6 +110,8 @@ export interface ToolResultNode { export interface UnknownSurfaceNode { kind: 'unknown' seq: number + /** Unix epoch ms from the source session event when known. */ + time: number type: string data: unknown } @@ -118,6 +132,8 @@ export interface RunningToolCall { argsRaw: string turn: number step: number + /** Unix epoch ms when the tool/call event was logged. */ + time: number /** Host-computed render intent riding the tool/call frame; null = generic JSON card. */ callView: ToolCallView | null } diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index ccb48a0161..0342bc3c85 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -18,6 +18,8 @@ export interface CallIndexEntry { argsRaw: string turn: number step: number + /** Unix epoch ms of the tool/call event. */ + time: number /** Wire view riding the tool/call (envelope-level; never inside the event). */ callView: ToolCallView | null } @@ -38,24 +40,34 @@ function materializeNode( ): ConversationNode { switch (event.type) { case 'user/message': - return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source } + return { + kind: 'user', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, + } case 'assistant/message': return { - kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step, + kind: 'assistant', seq: event.seq, time: event.time, + turn: event.data.turn, step: event.data.step, blocks: toAssistantBlocks(event.data.content), usage: event.data.usage, } case 'steering/message': - return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source } + return { + kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, + content: event.data.content, source: event.data.source, + } case 'context/message': return { - kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source, + kind: 'context', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, meta: event.data.meta, } case 'tool/result': { const call = callIndex.get(String(event.data.callId)) return { - kind: 'tool-result', seq: event.seq, callId: String(event.data.callId), + kind: 'tool-result', seq: event.seq, time: event.time, + callId: String(event.data.callId), call: call ? { name: call.name, argsRaw: call.argsRaw } : null, + callTime: call?.time ?? null, content: event.data.content, isError: event.data.isError, ...(event.data.error !== undefined ? { error: event.data.error } : {}), meta: event.data.meta, @@ -67,7 +79,10 @@ function materializeNode( surface-eligible types, and each has a case above; reachable only if core adds an eligible type. */ default: - return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data } + return { + kind: 'unknown', seq: event.seq, time: event.time, + type: event.type, data: (event as { data?: unknown }).data, + } } } @@ -186,6 +201,7 @@ export class FoldAdapter { if (event.type !== 'tool/call') return this.callIdx.set(String(event.data.callId), { name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step, + time: event.time, callView: view?.for === 'call' ? view.view : null, }) // No backfill into already-materialized tool-result nodes for this callId diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index c394141d85..6b773e0903 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -434,7 +434,7 @@ export class Session implements ObservableSnapshot { case 'tool/call': { this.openCalls.set(String(event.data.callId), { callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments, - turn: event.data.turn, step: event.data.step, + turn: event.data.turn, step: event.data.step, time: event.time, callView: view?.for === 'call' ? view.view : null, }) this.callsRev++ @@ -455,7 +455,8 @@ export class Session implements ObservableSnapshot { if (visible) { // Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn. this.frozenNodes.push({ - kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step, + kind: 'assistant', seq: event.seq - 0.9, time: event.time, + turn: this.partial.turn, step: this.partial.step, blocks, interrupted: true, }) this.frozenRev++ @@ -469,8 +470,10 @@ export class Session implements ObservableSnapshot { this.callsRev++ // The spinner card becomes an interrupted terminal card (never vanishes mid-flow). this.frozenNodes.push({ - kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId, + kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time, + callId, call: { name: call.name, argsRaw: call.argsRaw }, + callTime: call.time, content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, callView: call.callView, resultView: null, }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 2686a59ac2..4d3383b2d1 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -20,7 +20,7 @@ afterEach(cleanup) const SID = 's1' as SessionId const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({ - kind: 'assistant', seq, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }], + kind: 'assistant', seq, time: seq * 1_000, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }], ...(usage === undefined ? {} : { usage }), }) @@ -65,7 +65,7 @@ describe('deriveStats', () => { it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => { const tool: ToolResultNode = { - kind: 'tool-result', seq: 5, callId: 'c', call: null, content: [], + kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [], isError: false, callView: null, resultView: null, } const stats = deriveStats([tool, assistant(1, 1)]) @@ -112,8 +112,9 @@ describe('bash sample row', () => { const CHILD = 'child-1' as SessionId const result = (callId: string): ToolResultNode => ({ - kind: 'tool-result', seq: 3, callId, + kind: 'tool-result', seq: 3, time: 3_000, callId, call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' }, + callTime: 2_000, content: [], isError: false, callView: null, resultView: null, }) diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 828cf586fe..a221a4028b 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -12,12 +12,13 @@ import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/ const running = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}', - turn: 1, step: 1, callView: null, ...over, + turn: 1, step: 1, time: 1_000, callView: null, ...over, }) const result = (over?: Partial): ToolResultNode => ({ - kind: 'tool-result', seq: 10, callId: 'c1', + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' }, + callTime: 1_000, content: [], isError: false, callView: null, resultView: null, ...over, }) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 90c2f3090c..3f1db55199 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -54,18 +54,19 @@ function makeSource(init?: Partial) { } const user = (seq: number, text: string): UserMessageNode => ({ - kind: 'user', seq, content: [{ type: 'text', text }] as never, source: null, + kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null, }) const assistant = (seq: number, text: string): AssistantMessageNode => ({ - kind: 'assistant', seq, turn: 1, step: 1, blocks: [{ kind: 'text', text }], + kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }], }) const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ - kind: 'tool-result', seq, callId, + kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, + callTime: seq * 1_000 - 500, content: [], isError: false, callView: null, resultView: null, }) const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ - callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null, + callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, }) /** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */ diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 7f98cdd6ed..66ae3e802c 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -62,8 +62,9 @@ describe('tails', () => { it('a settled others-variant row renders the sparkle icon in the leading slot', () => { const settled: ToolResultNode = { - kind: 'tool-result', seq: 2, callId: 'c5', + kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5', call: { name: 'todo_write', argsRaw: '{"note":"x"}' }, + callTime: 1_000, content: [], isError: false, callView: null, resultView: null, } const props: ToolRowOwnerProps = { @@ -77,8 +78,9 @@ describe('tails', () => { it('BashRow shows the failed pill on error results (root session arm)', () => { const errorResult: ToolResultNode = { - kind: 'tool-result', seq: 1, callId: 'c1', + kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"boom"}' }, + callTime: 500, content: [], isError: true, callView: null, resultView: null, } // Root session (no parentId): the global arm renders, error pill visible. diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index b91fe229c3..10dba860c0 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -165,7 +165,7 @@ describe('DetailsPanel branches', () => { it('shows non-JSON args verbatim (streaming fragment path)', () => { const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, { - runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, callView: null }], + runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, time: 1_000, callView: null }], }) expect(view.getByText('{"cmd": tru')).toBeTruthy() }) @@ -176,7 +176,7 @@ describe('DetailsPanel branches', () => { }) it('snapshot updates re-run the material selector through the shallow equality arm', () => { - let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, callView: null }] } as ConversationSnapshot + let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, time: 1_000, callView: null }] } as ConversationSnapshot const subs = new Set<() => void>() const source = { getSnapshot: () => snap, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index a4243c8cb7..c923c19269 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -234,10 +234,11 @@ describe('DetailsPanel', () => { it('renders the selected call args and result off the shared store; close fires the injected callback', () => { const { closeDetails } = benchDetails({ nodes: [{ - kind: 'tool-result', callId: 'c1', + kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"cmd":"ls"}' }, + callTime: 500, content: [{ type: 'text', text: 'file-a\nfile-b' }], - isError: false, + isError: false, callView: null, resultView: null, }], }, { turnSeq: 1, callId: 'c1' }) expect(screen.getByText('bash')).toBeTruthy() @@ -248,10 +249,10 @@ describe('DetailsPanel', () => { }) it('shows the empty hint without a selection and the running state for open calls', () => { - benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, null) + benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null) expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy() cleanup() - benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, { turnSeq: 1, callId: 'c9' }) + benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' }) expect(screen.getByText('运行中…')).toBeTruthy() }) diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 96408ce4b9..e00ec415b7 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -169,6 +169,7 @@ body { --dsw-alias-border-l3: rgba(0, 0, 0, 0.12); --dsw-alias-border-l4: rgba(0, 0, 0, 0.16); --dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-1000); + --dsw-alias-brand-primary-new-colorprimary-new-color: rgb(65, 118, 230); --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-1000); --dsw-alias-brand-text: var(--dsw-static-neutral-bluish-1000); --dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-700); @@ -217,6 +218,7 @@ body { --dsw-alias-state-error-secondary: var(--dsw-static-red-400); --dsw-alias-state-success-primary: var(--dsw-static-green-500); --dsw-alias-state-success-secondary: var(--dsw-static-green-400); + --dsw-alias-state-success-tertiary: var(--dsw-static-green-100); --dsw-alias-state-warn-label: var(--dsw-static-amber-600); --dsw-alias-state-warn-primary: var(--dsw-static-amber-500); --dsw-alias-state-warn-secondary: var(--dsw-static-amber-400); @@ -257,11 +259,12 @@ body[data-ds-dark-theme] { --dsw-alias-border-l3: rgba(255, 255, 255, 0.16); --dsw-alias-border-l4: rgba(255, 255, 255, 0.2); --dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-50); + --dsw-alias-brand-primary-new-colorprimary-new-color: var(--dsw-static-deepseek-450); --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-50); --dsw-alias-brand-text: var(--dsw-static-neutral-bluish-50); --dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-50); --dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-750); - --dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-950); + --dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-850); --dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-800); --dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-600); --dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-750); diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index e3c2f6aade..f99a5c8386 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-trajectory -Trajectory/Waterfall placeholder views; the pure-consumer minimal plugin exemplar (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 turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. ## Model Experience @@ -12,4 +12,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Both views are placeholders by charter** — coarse span derivation with no visual acceptance bar; the real implementations, anchor deep-linking, and span-click selection handoff are the P-III project. +- **In-flight Time stays blank** — `partial` / `runningCalls` rows render with `—` until a live clock policy lands; selected styling is local-only (not wired to chat details); anchor deep-linking remains deferred. diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css new file mode 100644 index 0000000000..1120fe2746 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css @@ -0,0 +1,93 @@ +/* Trajectory step cell — 38px row: index · kind tag · text · optional message + * metrics · elapsed time. */ + +.root { + display: flex; + align-items: center; + box-sizing: border-box; + height: 38px; + padding: 0 8px 0 20px; + gap: 24px; + border-radius: 8px; + border: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-3); + min-width: 0; +} + +.selected { + border-color: transparent; + box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary-new-colorprimary-new-color); +} + +.index { + flex: none; + width: 24px; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} + +.tagSlot { + flex: none; + width: 80px; + display: flex; + align-items: center; + min-width: 0; +} + +.tag { + display: inline-flex; + align-items: center; + box-sizing: border-box; + height: 22px; + max-width: 100%; + padding: 0 4px; + border-radius: 6px; + font: var(--dsw-font-xs-strong-13); + white-space: nowrap; +} + +.tagUser { + color: var(--dsw-alias-state-success-primary); + background: var(--dsw-alias-state-success-tertiary); +} + +.tagMessage { + color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); + background: var(--dsw-specific-bubble); +} + +.tagTool { + color: var(--dsw-alias-state-warn-label); + background: var(--dsw-alias-state-warn-tertiary); +} + +.text { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-primary); +} + +/* Same column geometry as TrajectoryTurnHeader: 4×71 + 3×12 = 320. */ +.trailing { + flex: none; + display: flex; + align-items: center; + justify-content: flex-end; + width: 320px; + gap: 12px; + min-width: 0; +} + +.metric, +.time { + flex: none; + width: 71px; + text-align: left; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx new file mode 100644 index 0000000000..de99d027d8 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -0,0 +1,99 @@ +// TrajectoryCell: one step row in the trajectory list — index, kind tag, +// ellipsis text, optional Message token metrics, and own-duration time. + +import type { HTMLAttributes } from 'react' +import css from './TrajectoryCell.module.css' + +/** Closed set of trajectory step kinds (call+result fold into Tool; no Think). */ +export type TrajectoryCellKind = 'user' | 'message' | 'tool' + +/** Display label per kind (matches the design tags). */ +const KIND_LABEL: Record = { + user: 'User', + message: 'Message', + tool: 'Tool', +} + +const TAG_CLASS: Record = { + user: css.tagUser!, + message: css.tagMessage!, + tool: css.tagTool!, +} + +export interface TrajectoryCellProps extends HTMLAttributes { + /** 1-based step index shown as `#N`. */ + index: number + kind: TrajectoryCellKind + /** Single-line summary; CSS ellipsis when it overflows. */ + text: string + /** + * Own duration in seconds. `null` means no duration to show (em dash) — + * used for in-flight tools and tools missing callTime. + */ + timeSeconds: number | null + /** Message-only: prompt token count. */ + input?: number + /** Message-only: completion token count. */ + output?: number + /** Message-only: reasoning token count (usage column, not a Think cell). */ + think?: number + /** Selected: 2px inset brand-primary-new-color ring (not wired to chat selection yet). */ + selected?: boolean +} + +/** + * Format own-duration for the trailing time column: `—` when unknown, `+Ns` + * or `+N.1s` otherwise. + * @param seconds - duration seconds, or null when absent. + * @returns display string. + */ +export function formatElapsedSeconds(seconds: number | null): string { + if (seconds === null || !Number.isFinite(seconds)) return '—' + const rounded = Math.round(seconds * 10) / 10 + if (Number.isInteger(rounded)) return `+${rounded}s` + return `+${rounded.toFixed(1)}s` +} + +/** + * Render one trajectory step cell. + * @param props - index, kind, text, time, and optional Message metrics. + * @returns the cell element. + */ +export function TrajectoryCell({ + index, + kind, + text, + timeSeconds, + input, + output, + think, + selected = false, + className, + ...rest +}: TrajectoryCellProps) { + const rootClass = [ + css.root, + selected ? css.selected : undefined, + className, + ].filter((c): c is string => c !== undefined).join(' ') + const showMetrics = kind === 'message' + return ( +
+ #{index} + + {KIND_LABEL[kind]} + + {text} + + {showMetrics ? ( + <> + {input ?? ''} + {output ?? ''} + {think ?? ''} + + ) : null} + {formatElapsedSeconds(timeSeconds)} + +
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css new file mode 100644 index 0000000000..6de7074aaa --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css @@ -0,0 +1,27 @@ +/* Message / Step group title row inside a turn body. */ + +.root { + display: flex; + align-items: center; + box-sizing: border-box; + height: 36px; + padding: 0 20px; + gap: 24px; + min-width: 0; +} + +.title { + flex: none; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-primary); +} + +.description { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx new file mode 100644 index 0000000000..90252ce373 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx @@ -0,0 +1,26 @@ +// TrajectoryGroupHeader: "Message" or "Step N" row with optional description. + +import css from './TrajectoryGroupHeader.module.css' + +export interface TrajectoryGroupHeaderProps { + /** Group title (`Message`, `Step 1`, …). */ + title: string + /** Secondary summary (`49s`, `2.2s skill`, …). */ + description?: string +} + +/** + * Render a Message/Step group header inside a turn body. + * @param props - title and optional description. + * @returns the group header element. + */ +export function TrajectoryGroupHeader({ title, description }: TrajectoryGroupHeaderProps) { + return ( +
+ {title} + {description !== undefined && description !== '' + ? {description} + : null} +
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css new file mode 100644 index 0000000000..c1f243c8b9 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css @@ -0,0 +1,16 @@ +/* One turn block: sticky header + padded body with 10px item gap. */ + +.root { + width: 100%; +} + +.body { + display: flex; + flex-direction: column; + gap: 10px; + box-sizing: border-box; + width: 100%; + max-width: 880px; + margin: 0 auto; + padding: 8px 16px 22px; +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx new file mode 100644 index 0000000000..6ebce17731 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx @@ -0,0 +1,26 @@ +// TrajectoryTurn: sticky Turn header plus the padded Message/Step body. + +import type { ReactNode } from 'react' +import { TrajectoryTurnHeader } from './TrajectoryTurnHeader.tsx' +import css from './TrajectoryTurn.module.css' + +export interface TrajectoryTurnProps { + /** 1-based turn index for the sticky header. */ + turn: number + /** Message / Step headers and TrajectoryCell rows. */ + children?: ReactNode +} + +/** + * Render one turn section (sticky header + body). + * @param props - turn index and body children. + * @returns the turn section element. + */ +export function TrajectoryTurn({ turn, children }: TrajectoryTurnProps) { + return ( +
+ +
{children}
+
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css new file mode 100644 index 0000000000..4aaed68551 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css @@ -0,0 +1,48 @@ +/* Sticky turn bar: full-bleed ghost-active fill across the panel; title + + * metric labels sit in a centered 880 content lane (4×71 + 3×12 = 320). */ + +.root { + position: sticky; + top: 0; + z-index: 1; + box-sizing: border-box; + width: 100%; + height: 44px; + background: var(--dsw-alias-button-ghost-active-fill); +} + +.inner { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + width: 100%; + max-width: 880px; + height: 100%; + margin: 0 auto; + padding: 0 16px; +} + +.title { + flex: none; + font: var(--dsw-font-xs-strong-13); + color: var(--dsw-alias-label-primary); +} + +.columns { + flex: none; + display: flex; + align-items: center; + width: 320px; + gap: 12px; + /* Match cell padding-right: 8 so Time lines up with the trailing lane. */ + margin-right: 8px; +} + +.column { + flex: none; + width: 71px; + text-align: left; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-secondary); +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx new file mode 100644 index 0000000000..ba54ed1c34 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx @@ -0,0 +1,30 @@ +// TrajectoryTurnHeader: sticky per-turn bar with Input/Output/Think/Time labels. + +import css from './TrajectoryTurnHeader.module.css' + +const COLUMN_LABELS = ['Input', 'Output', 'Think', 'Time'] as const + +export interface TrajectoryTurnHeaderProps { + /** 1-based turn index shown as `Turn N`. */ + turn: number +} + +/** + * Render the sticky turn header row. + * @param props.turn - turn index. + * @returns the sticky header element. + */ +export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) { + return ( +
+
+ Turn {turn} + +
+
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 0ccb298801..45277eb628 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,30 +1,40 @@ -// TrajectoryView: P-I placeholder body for the trajectory tab — span stats -// header over a per-turn span list with node-count weights (no timing data -// exists yet; deviation ledger #3 defers real rendering to P-III). +// TrajectoryView: sticky Turn sections with Message/Step groups and step cells. import { useMemo } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { deriveSpans } from './spans.ts' -import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx' +import { TrajectoryCell } from './TrajectoryCell.tsx' +import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx' +import { TrajectoryTurn } from './TrajectoryTurn.tsx' +import { deriveTrajectoryLayout } from './layout.ts' import css from './views.module.css' export function TrajectoryView({ useSession }: ConvViewProps) { const nodes = useSession((s) => s.nodes) - const spans = useMemo(() => deriveSpans(nodes), [nodes]) - if (spans.length === 0) return

暂无轨迹数据

+ const partial = useSession((s) => s.partial) + const runningCalls = useSession((s) => s.runningCalls) + const turns = useMemo( + () => deriveTrajectoryLayout({ nodes, partial, runningCalls }), + [nodes, partial, runningCalls], + ) + if (turns.length === 0) { + return

暂无轨迹数据

+ } return ( - <> - -
- {spans.map((span) => ( -
- turn {span.turn} - - {span.steps} steps · {span.calls} calls · {span.nodes} nodes - -
- ))} -
- +
+ {turns.map((turn) => ( + + {turn.groups.flatMap((group) => [ + , + ...group.cells.map((cell) => ( + + )), + ])} + + ))} +
) } diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 3979bfd91b..4a902fe9ff 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -24,8 +24,8 @@ export const inject = ['slots', 'conversation'] /** * Client plugin body: register the trajectory and waterfall view tabs. The * registrations ride the slot service's effect wrapper (plugin unload - * removes both tabs); the span stats header renders inside each view body - * (the chrome attachment mechanism retired with the view ring). + * removes both tabs). Trajectory owns its turn list in-body; Waterfall keeps + * the span stats header inside its body (chrome attachment retired). * @param ctx - client root context. */ export function apply(ctx: Context): void { diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts new file mode 100644 index 0000000000..03bfcb6893 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -0,0 +1,374 @@ +/** + * Trajectory list fold: expand assistant blocks, attach usage to Message, + * own-duration times, in-flight partial/runningCalls, and group descriptions. + */ +import type { + AssistantMessageNode, + ConversationSnapshot, + ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { TrajectoryCellProps } from './TrajectoryCell.tsx' + +/** One Message or Step group inside a turn. */ +export interface TrajectoryGroupModel { + title: string + description?: string + cells: readonly TrajectoryCellProps[] +} + +/** One sticky-turn section. */ +export interface TrajectoryTurnModel { + turn: number + groups: readonly TrajectoryGroupModel[] +} + +/** Snapshot slice the trajectory view folds. */ +export interface TrajectoryLayoutInput { + nodes: ConversationSnapshot['nodes'] + partial: ConversationSnapshot['partial'] + runningCalls: ConversationSnapshot['runningCalls'] +} + +interface UsageLike { + inputTokens?: number + outputTokens?: number + reasoningTokens?: number +} + +/** Cell plus absolute ms for group wall-span descriptions. */ +interface LaidCell { + cell: TrajectoryCellProps + absTime: number | null + toolName?: string + callId?: string +} + +/** + * Fold a snapshot into turn → Message/Step groups with expanded cells. + * @param input - nodes plus in-flight partial/runningCalls. + * @returns turns ordered by first appearance. + */ +export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] { + const { nodes, partial, runningCalls } = input + const resultByCall = indexResults(nodes) + const turns = new Map }>() + let index = 0 + let prevAbsTime: number | null = null + + const bucket = (turn: number) => { + let entry = turns.get(turn) + if (entry === undefined) { + entry = { message: [], steps: new Map() } + turns.set(turn, entry) + } + return entry + } + + const pushMessage = (turn: number, laid: LaidCell) => { + bucket(turn).message.push(laid) + } + const pushStep = (turn: number, step: number, laid: LaidCell) => { + const steps = bucket(turn).steps + const list = steps.get(step) ?? [] + list.push(laid) + steps.set(step, list) + } + + for (const node of nodes) { + if (node.kind === 'user' || node.kind === 'steering') { + const turn = node.kind === 'steering' ? node.turn : 0 + pushMessage(turn, { + absTime: finiteTime(node.time), + cell: { + index: ++index, kind: 'user', text: summarizeContent(node.content), + timeSeconds: 0, + }, + }) + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + continue + } + if (node.kind === 'assistant') { + const laidList = expandAssistant(node, index + 1, prevAbsTime, resultByCall) + for (const laid of laidList) { + if (node.step > 0) pushStep(node.turn, node.step, laid) + else pushMessage(node.turn, laid) + } + const last = laidList[laidList.length - 1] + if (last !== undefined) index = last.cell.index + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + continue + } + if (node.kind === 'tool-result') { + if (!callEmittedInAssistant(nodes, node.callId)) { + const toolName = node.call?.name + pushStep(0, 1, { + absTime: finiteTime(node.callTime ?? node.time), + ...(toolName !== undefined ? { toolName } : {}), + callId: node.callId, + cell: { + index: ++index, + kind: 'tool', + text: node.call !== null + ? summarizeCall(node.call.name, node.call.argsRaw) + : summarizeResult(node), + timeSeconds: durationSeconds(node.time, node.callTime), + }, + }) + } + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + } + } + + if (partial !== null) { + const fake: AssistantMessageNode = { + kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0, + turn: partial.turn, step: partial.step, blocks: partial.blocks, + } + const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true }) + for (const laid of laidList) { + if (partial.step > 0) pushStep(partial.turn, partial.step, laid) + else pushMessage(partial.turn, laid) + } + const last = laidList[laidList.length - 1] + if (last !== undefined) index = last.cell.index + } + + const seenCalls = collectCallIds(turns) + for (const call of runningCalls) { + if (seenCalls.has(call.callId)) continue + pushStep(call.turn, call.step > 0 ? call.step : 1, { + absTime: null, + toolName: call.name, + callId: call.callId, + cell: { + index: ++index, + kind: 'tool', + text: summarizeCall(call.name, call.argsRaw), + timeSeconds: null, + }, + }) + } + + const prologue = turns.get(0) + if (prologue !== undefined) { + turns.delete(0) + const emptyTurn = (): { message: LaidCell[]; steps: Map } => ({ + message: [], + steps: new Map(), + }) + const first = turns.get(1) ?? emptyTurn() + first.message = [...prologue.message, ...first.message] + for (const [step, cells] of prologue.steps) { + const existing = first.steps.get(step) ?? [] + first.steps.set(step, [...cells, ...existing]) + } + turns.set(1, first) + } + + return [...turns.entries()] + .sort(([a], [b]) => a - b) + .map(([turn, entry]) => toTurnModel(turn, entry)) +} + +function toTurnModel( + turn: number, + entry: { message: LaidCell[]; steps: Map }, +): TrajectoryTurnModel { + const groups: TrajectoryGroupModel[] = [] + if (entry.message.length > 0) { + const description = groupDescription(entry.message) + groups.push({ + title: 'Message', + ...(description !== undefined ? { description } : {}), + cells: entry.message.map(l => l.cell), + }) + } + for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) { + const laid = entry.steps.get(step) ?? [] + const description = groupDescription(laid) + groups.push({ + title: `Step ${step}`, + ...(description !== undefined ? { description } : {}), + cells: laid.map(l => l.cell), + }) + } + return { turn, groups } +} + +/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */ +function groupDescription(laid: readonly LaidCell[]): string | undefined { + const parts: string[] = [] + // Tool rows contribute start (absTime) and end (start + own duration) so a + // single Tool cell still spans call→result for the group wall clock. + const times: number[] = [] + for (const l of laid) { + if (l.absTime === null || !Number.isFinite(l.absTime)) continue + times.push(l.absTime) + if (l.cell.kind === 'tool' && l.cell.timeSeconds !== null && Number.isFinite(l.cell.timeSeconds)) { + times.push(l.absTime + l.cell.timeSeconds * 1000) + } + } + if (times.length >= 2) { + const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000) + if (span !== undefined) parts.push(span) + } else if (times.length === 1) { + const own = laid.find(l => l.absTime === times[0])?.cell.timeSeconds + const span = own !== null && own !== undefined ? formatGroupDuration(own) : undefined + if (span !== undefined) parts.push(span) + } + const tools = new Map() + for (const l of laid) { + if (l.toolName === undefined || l.cell.kind !== 'tool') continue + tools.set(l.toolName, (tools.get(l.toolName) ?? 0) + 1) + } + for (const [name, count] of tools) { + parts.push(count > 1 ? `${name}×${count}` : name) + } + return parts.length === 0 ? undefined : parts.join(' ') +} + +function formatGroupDuration(seconds: number): string | undefined { + if (!Number.isFinite(seconds)) return undefined + const rounded = Math.round(seconds * 10) / 10 + if (Number.isInteger(rounded)) return `${rounded}s` + return `${rounded.toFixed(1)}s` +} + +/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */ +function durationSeconds(later: number, earlier: number | null): number | null { + if (earlier === null || !Number.isFinite(later) || !Number.isFinite(earlier)) return null + return Math.max(0, (later - earlier) / 1000) +} + +/** Epoch-ms usable as an absolute time, else null. */ +function finiteTime(time: number): number | null { + return Number.isFinite(time) ? time : null +} + +function expandAssistant( + node: AssistantMessageNode, + startIndex: number, + prevAbsTime: number | null, + results: Map, + opts?: { streaming?: boolean }, +): LaidCell[] { + const out: LaidCell[] = [] + let index = startIndex - 1 + const usage = node.usage as UsageLike | undefined + const streaming = opts?.streaming === true + const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime) + const nodeAbs = streaming ? null : finiteTime(node.time) + let usageAttached = false + + for (const block of node.blocks) { + // Reasoning blocks are skipped: no block-level clock, so no Think cell. + if (block.kind === 'reasoning') continue + if (block.kind === 'text') { + if (block.text === '' && streaming) continue + const cell: TrajectoryCellProps = { + index: ++index, kind: 'message', text: summarizeText(block.text), + timeSeconds: messageDuration, + } + if (!usageAttached && usage !== undefined) { + if (usage.inputTokens !== undefined) cell.input = usage.inputTokens + if (usage.outputTokens !== undefined) cell.output = usage.outputTokens + if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens + usageAttached = true + } + out.push({ absTime: nodeAbs, cell }) + continue + } + if (block.kind === 'tool-call') { + const result = results.get(block.callId) + const toolDuration = streaming || result === undefined + ? null + : durationSeconds(result.time, result.callTime) + const callAbs = streaming + ? null + : (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime) + ? result.callTime + : nodeAbs) + out.push({ + absTime: callAbs, + toolName: block.name, + callId: block.callId, + cell: { + index: ++index, kind: 'tool', + text: summarizeCall(block.name, block.argsRaw), + timeSeconds: toolDuration, + }, + }) + } + } + + if (out.length === 0 && !streaming) { + out.push({ + absTime: nodeAbs, + cell: { index: ++index, kind: 'message', text: '', timeSeconds: messageDuration }, + }) + } + return out +} + +function indexResults(nodes: ConversationSnapshot['nodes']): Map { + const map = new Map() + for (const node of nodes) { + if (node.kind === 'tool-result') map.set(node.callId, node) + } + return map +} + +function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean { + for (const node of nodes) { + if (node.kind !== 'assistant') continue + if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true + } + return false +} + +function collectCallIds( + turns: Map }>, +): Set { + const ids = new Set() + for (const entry of turns.values()) { + for (const laid of entry.message) { + if (laid.callId !== undefined) ids.add(laid.callId) + } + for (const list of entry.steps.values()) { + for (const laid of list) { + if (laid.callId !== undefined) ids.add(laid.callId) + } + } + } + return ids +} + +function summarizeCall(name: string, argsRaw: string): string { + const args = argsRaw.replace(/\s+/g, ' ').trim() + if (args === '') return name + const clipped = args.length > 72 ? `${args.slice(0, 71)}…` : args + return `${name} · ${clipped}` +} + +function summarizeResult(node: ToolResultNode): string { + if (node.isError) { + return node.error?.code ?? 'error' + } + for (const block of node.content) { + if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') { + return summarizeText(block.text) + } + } + return node.call?.name ?? node.callId +} + +function summarizeContent(content: readonly { type: string; text?: string }[]): string { + for (const block of content) { + if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text) + } + return '' +} + +function summarizeText(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 951a5e2705..d3089b3568 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -1,25 +1,34 @@ +/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge; + * cell content width is capped on the turn body (max 880). */ .root { - padding: 16px; overflow-y: auto; + height: 100%; + min-height: 0; + width: 100%; + box-sizing: border-box; color: var(--dsw-alias-label-primary); - font-size: 13px; + background: var(--dsw-specific-sidebar-fill); } .empty { + padding: 16px; color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); } +/* Waterfall placeholder rows (shared module). */ .row { display: flex; align-items: center; gap: 8px; - padding: 4px 0; + padding: 4px 16px; } .turnTag { flex: none; width: 64px; color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); } .bar { @@ -29,9 +38,10 @@ } .barCalls { - background: var(--dsw-alias-brand-primary); + background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); } .meta { color: var(--dsw-alias-label-caption); + font: var(--dsw-font-xs-13); } diff --git a/packages/client/ui-trajectory/tests/cell.spec.tsx b/packages/client/ui-trajectory/tests/cell.spec.tsx new file mode 100644 index 0000000000..d9c9004622 --- /dev/null +++ b/packages/client/ui-trajectory/tests/cell.spec.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +/** + * TrajectoryCell presentation: kind tags, ellipsis-hosting text, Message + * metric columns, own-duration formatting, and selected ring. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { + formatElapsedSeconds, + TrajectoryCell, + type TrajectoryCellKind, +} from '../src/client/TrajectoryCell.tsx' + +afterEach(cleanup) + +describe('formatElapsedSeconds', () => { + it('formats known durations and uses an em dash when absent', () => { + expect(formatElapsedSeconds(null)).toBe('—') + expect(formatElapsedSeconds(235)).toBe('+235s') + expect(formatElapsedSeconds(235.0)).toBe('+235s') + expect(formatElapsedSeconds(235.2)).toBe('+235.2s') + expect(formatElapsedSeconds(235.25)).toBe('+235.3s') + expect(formatElapsedSeconds(0)).toBe('+0s') + expect(formatElapsedSeconds(Number.NaN)).toBe('—') + }) +}) + +describe('TrajectoryCell', () => { + it('renders index, kind tag, text, and time for a Tool row', () => { + render( + , + ) + expect(screen.getByText('#6')).toBeTruthy() + expect(screen.getByText('Tool')).toBeTruthy() + expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy() + expect(screen.getByText('+5s')).toBeTruthy() + }) + + it('Message rows expose Input / Output / Think metric columns before time', () => { + const { container } = render( + , + ) + expect(screen.getByText('Message')).toBeTruthy() + expect(screen.getByText('136')).toBeTruthy() + expect(screen.getByText('381')).toBeTruthy() + expect(screen.getByText('155')).toBeTruthy() + expect(screen.getByText('+235.2s')).toBeTruthy() + const texts = [...container.querySelectorAll('span')].map((el) => el.textContent) + expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381')) + expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155')) + expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s')) + }) + + it('selected marks the row for the brand-primary inset ring', () => { + const { container } = render( + , + ) + expect(container.firstElementChild?.getAttribute('data-selected')).toBe('true') + }) + + it.each([ + ['user', 'User'], + ['tool', 'Tool'], + ] as const)('kind %s shows the %s tag and no metric columns', (kind: TrajectoryCellKind, label: string) => { + const { container } = render( + , + ) + expect(screen.getByText(label)).toBeTruthy() + expect(container.querySelector('[data-kind]')?.getAttribute('data-kind')).toBe(kind) + expect(screen.queryByText('1')).toBeNull() + expect(screen.queryByText('2')).toBeNull() + expect(screen.queryByText('3')).toBeNull() + }) +}) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx new file mode 100644 index 0000000000..4dc395221d --- /dev/null +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +/** + * Trajectory turn chrome and layout fold: expand blocks, usage on Message, + * tool own-duration, group wall-span descriptions, in-flight rows. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' +import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' +import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx' +import { deriveTrajectoryLayout } from '../src/client/layout.ts' + +afterEach(cleanup) + +describe('TrajectoryTurnHeader', () => { + it('renders Turn N and the four metric column labels', () => { + render() + expect(screen.getByText('Turn 1')).toBeTruthy() + expect(screen.getByText('Input')).toBeTruthy() + expect(screen.getByText('Output')).toBeTruthy() + expect(screen.getByText('Think')).toBeTruthy() + expect(screen.getByText('Time')).toBeTruthy() + }) +}) + +describe('TrajectoryGroupHeader', () => { + it('renders title and optional description', () => { + render() + expect(screen.getByText('Step 1')).toBeTruthy() + expect(screen.getByText('2.2s skill')).toBeTruthy() + }) + + it('omits the description node when absent', () => { + const { container } = render() + expect(screen.getByText('Message')).toBeTruthy() + expect(container.querySelectorAll('span')).toHaveLength(1) + }) +}) + +describe('TrajectoryTurn', () => { + it('wraps a sticky header and body children', () => { + render( + + + , + ) + expect(screen.getByText('Turn 3')).toBeTruthy() + expect(screen.getByText('Message')).toBeTruthy() + expect(screen.getByText('49s')).toBeTruthy() + }) +}) + +describe('deriveTrajectoryLayout', () => { + it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => { + const nodes = [ + { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null }, + { + kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1, + blocks: [ + { kind: 'reasoning', text: 'thinking…' }, + { kind: 'text', text: 'I will run bash' }, + { kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls"}' }, + ], + usage: { inputTokens: 10, outputTokens: 20, reasoningTokens: 5 }, + }, + { + kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1', + call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200, + content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns).toHaveLength(1) + expect(turns[0]?.turn).toBe(1) + const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind)) + expect(kinds).toEqual(['user', 'message', 'tool']) + const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message') + expect(message).toMatchObject({ + input: 10, output: 20, think: 5, timeSeconds: 5, + }) + const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool') + expect(tool?.text).toBe('bash · {"command":"ls"}') + expect(tool?.timeSeconds).toBe(1.3) + }) + + it('adds runningCalls not already present and leaves their time blank', () => { + const turns = deriveTrajectoryLayout({ + nodes: [] as unknown as ConversationSnapshot['nodes'], + partial: null, + runningCalls: [{ + callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}', + turn: 1, step: 2, time: 9_000, callView: null, + }], + }) + expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2']) + expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({ + kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null, + }) + }) + + it('omits duration when node times are missing instead of rendering NaN', () => { + const nodes = [ + { kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null }, + { + kind: 'assistant', seq: 2, turn: 1, step: 1, + blocks: [ + { kind: 'reasoning', text: '…' }, + { kind: 'text', text: 'ok' }, + ], + usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 }, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? [] + expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull() + expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined() + }) + + it('builds a wall-span step description with a tool histogram', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, + blocks: [ + { kind: 'tool-call', callId: 'a', name: 'bash', argsRaw: '{}' }, + { kind: 'tool-call', callId: 'b', name: 'bash', argsRaw: '{}' }, + ], + }, + { + kind: 'tool-result', seq: 2, time: 2_500, callId: 'a', + call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100, + content: [], isError: false, callView: null, resultView: null, + }, + { + kind: 'tool-result', seq: 3, time: 4_000, callId: 'b', + call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600, + content: [], isError: false, callView: null, resultView: null, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2') + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index a3ac84738e..4818e67db5 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -3,9 +3,9 @@ * View registration acceptance on the real framework stack: the plugin fiber * registers trajectory/waterfall into a real SlotsService view ring, tabs * switch inside ConversationRoot (renderSlot share driven by the same tab - * projection apply uses) without collapsing chat, the span stats header - * renders inside both view bodies, and fiber disposal removes both tabs. - * Span derivation edge cases ride along. + * projection apply uses) without collapsing chat, trajectory renders the + * turn-list chrome (no span stats bar), waterfall keeps in-body stats, and + * fiber disposal removes both tabs. Span derivation edge cases ride along. */ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -36,19 +36,26 @@ afterEach(cleanup) // The chat store persists under its declared key; clear so one case's active // view cannot rehydrate into the next. beforeEach(() => { - localStorage.clear() + // Node 22+ exposes an experimental localStorage global that is undefined + // without --localstorage-file; only clear when a real Storage is present. + if (typeof localStorage !== 'undefined') localStorage.clear() }) /** Node fixture: user prologue, two turns, one tool result inside turn 1. */ const NODES = [ - { kind: 'user', seq: 1, content: [], source: null }, - { kind: 'assistant', seq: 2, turn: 1, step: 1, blocks: [] }, - { kind: 'tool-result', seq: 3, callId: 'c1', call: null, content: [], isError: false, callView: null, resultView: null }, - { kind: 'assistant', seq: 4, turn: 2, step: 1, blocks: [] }, + { 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, + content: [], isError: false, callView: null, resultView: null, + }, + { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] }, ] as unknown as ConversationSnapshot['nodes'] function fakeSession(nodes: ConversationSnapshot['nodes']) { - const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes }) + const store = createSnapshotStore({ + nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], + }) return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } } @@ -99,8 +106,9 @@ function tabsOf(slots: SlotsService): ViewTab[] { /** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) { - const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({ + const sessionSnapshot = createSnapshotStore({ running: false, removed: false, promptError: null, nodes, + partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], }) const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession const chat = createChatStore().create() @@ -158,17 +166,20 @@ describe('plugin registration', () => { }) describe('tab switching in ConversationRoot', () => { - it('renders all three tabs, defaults to chat, and switches to trajectory with its header stats', async () => { + it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => { const b = await bench() mount(b.slots) expect(screen.getByTestId('chat-body')).toBeTruthy() expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall']) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - // In-body header stats over NODES: turns 0/1/2, 2 assistant steps, 1 tool call. - expect(screen.getByText('3 turns · 2 steps · 1 tool calls')).toBeTruthy() - expect(screen.getByText('turn 0')).toBeTruthy() - expect(screen.getByText('1 steps · 1 calls · 2 nodes')).toBeTruthy() + // Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body. + expect(screen.queryByText(/turns ·/)).toBeNull() + expect(screen.getByText('Turn 1')).toBeTruthy() + expect(screen.getByText('Turn 2')).toBeTruthy() + expect(screen.getAllByText('Message').length).toBeGreaterThan(0) + expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0) + expect(screen.getAllByText('Input').length).toBeGreaterThan(0) expect(screen.queryByTestId('chat-body')).toBeNull() })