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 new file mode 100644 index 0000000000..035cb14e90 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +2026-07-27-trajectory-inspection-ledger.md: 30d1a2b0b43c8ca134f934c7197f85ff03c65742 +2026-07-27-trajectory-inspection-ledger.zh.md: 2c8724160ca25a4bd9ae4cdddd5b1e07bc1d808c 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 new file mode 100644 index 0000000000..30d1a2b0b4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -0,0 +1,36 @@ +# Agent Note: Trajectory inspection ledger + +Status: implemented + +English | [中文](2026-07-27-trajectory-inspection-ledger.zh.md) + +## Problem + +Trajectory has to make prose, machine payloads, token usage, timing, and nested tool activity readable in the same viewport. The earlier stacked Turn and Step cards preserved hierarchy but spent too much vertical space on repeated chrome, while a completely flat table would erase the causal structure that makes a trajectory useful. Role colors also risked borrowing success and warning semantics, which made visual decoration indistinguishable from runtime state. + +## Decision + +**Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.** + +- Turn boundaries are thick rules between record rows, while each Step appears as a compact inline marker on its first record. Individual User, Assistant, Tool, and Subtool events share stable columns for index, event kind, and content; token usage and duration stay in the inspector, a thin timeline rail preserves sequence, and nested subtools receive a small indentation. +- Product prose continues to use the existing sans stack. Record indexes, token counts, durations, group summaries, tool calls, and raw payloads use the existing code stack because they are machine data. +- Existing semantic theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; business blue is limited to Assistant identity, selection, links, and focus; warning is limited to running work; error is limited to failed work. User and Tool roles do not impersonate runtime states. +- Entity surfaces stay flat and separated by hairline borders. Shadow appears only when the inspector becomes an overlay at narrow widths. +- Selecting a record opens an inspector inside Trajectory with Overview, Input, Output, and Timing tabs. This state is deliberately independent from the conversation-wide Chat details column: it inspects a trajectory record without changing the user's Chat context. +- The three-column ledger reserves its width for record content. At narrow widths the inspector overlays the ledger and remains dismissible by keyboard or pointer. + +## Alternatives considered + +**Copy Vite DevTools fonts, colors, glass surfaces, or component shapes.** Rejected: those choices express a different product identity. The implementation only adopts the transferable method: neutral structure, semantic accents, machine-data typography, dense scanning, and shadows reserved for floating layers. + +**Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower. + +**Flatten every record without turn rules or step markers.** Rejected: a trajectory is not merely a log stream; Turn and Step boundaries are essential causal landmarks even when they do not consume dedicated rows. + +**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. + +**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 Step orientation. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payload and assistant timing. The inspector floats over the table only when a permanent split would make both panes unusable. Focused component tests pin the ledger, fold control, keyboard selection, payload tabs, timing facts, and running/error semantics; the assembled Web snapshot pins the real seeded session with the local inspector open. 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 new file mode 100644 index 0000000000..2c8724160c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -0,0 +1,36 @@ +# Agent Note:轨迹检查记录表 + +Status: implemented + +[English](2026-07-27-trajectory-inspection-ledger.md) | 中文 + +## 问题 + +轨迹视图需要在同一视口内清晰呈现正文、机器载荷、token 用量、计时数据和嵌套工具活动。此前堆叠式的轮次与步骤卡片虽然保留了层级,却在重复界面框架上耗费了太多垂直空间;完全扁平化的表格又会抹去因果结构,而这种结构正是轨迹视图的价值所在。角色配色还可能借用成功与警告语义,使视觉装饰与运行时状态无法区分。 + +## 决策 + +**使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** + +- 轮次边界由记录行之间较粗的分割线表示,每个步骤在其首条记录上以紧凑的行内标记呈现。用户、助手、工具和子工具事件共用稳定的索引、事件类型和内容列;token 用量与耗时留在检查器中,细线时间轴保留事件顺序,嵌套子工具则采用小幅缩进。 +- 产品正文继续使用现有无衬线字体栈。记录索引、token 数、耗时、分组摘要、工具调用和原始载荷属于机器数据,因此使用现有代码字体栈。 +- 现有语义主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;业务蓝色仅用于助手身份、选择状态、链接和焦点;警告色仅用于运行中的工作;错误色仅用于失败的工作。用户和工具角色不借用运行时状态的视觉语义。 +- 各记录表面保持平面化,并以细线边框分隔。只有在窄屏下检查器变为浮层时才使用阴影。 +- 选择记录后,轨迹视图内部会打开包含概览、输入、输出和计时标签页的检查器。该状态有意与会话级 Chat 详情栏相互独立:检查轨迹记录不会改变用户在 Chat 中的上下文。 +- 三列记录表将宽度留给记录内容。在窄屏下,检查器会覆盖在记录表上,并且仍可通过键盘或指针关闭。 + +## 曾考虑的替代方案 + +**照搬 Vite DevTools 的字体、颜色、玻璃表面或组件形状。** 不予采纳:这些选择表达的是另一种产品身份。实现仅吸收可迁移的方法,即中性结构、语义强调色、机器数据排版、紧凑扫读,以及只为浮层保留阴影。 + +**每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。 + +**不使用轮次分割线与步骤标记,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;即使轮次与步骤边界不再占用独立行,它们仍是不可缺少的关键因果标记。 + +**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 + +**修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。 + +## 后果 + +轨迹视图在保留轮次与步骤定位的同时,每个视口可以显示更多有效记录。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器则将这些数据与完整载荷、助手计时一并展示。只有固定分栏会让两个面板都无法使用时,检查器才浮在表格之上。针对性组件测试锁定事件记录表、折叠控制、键盘选择、载荷标签页、计时数据和运行/错误语义;组装后的 Web 快照则锁定真实预置会话在局部检查器打开时的渲染结果。 diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index bbae7363df..a4abc5cecd 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -124,17 +124,24 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) }, 60_000) - it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => { + 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() - // Two sticky turn sections; turn 1's step group summarizes its tool mix - // (bash + the two parallel reads collapse to 'bash read×2'). - await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1) - await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1) + // Thick row rules mark the two turns; compact inline markers identify + // each step without introducing dedicated group rows. + await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2) + await expect.poll(() => page.locator('[aria-label^="Step "]').count(), { timeout: 10_000 }).toBe(3) + await expect.poll(() => page.getByRole('columnheader', { name: 'Tokens' }).count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => page.getByRole('columnheader', { name: '耗时' }).count(), { timeout: 10_000 }).toBe(0) + await page.locator('tr[data-kind="tool"]').first().click() + await expect.poll(() => page.getByRole('complementary', { name: '记录详情' }).count(), { timeout: 10_000 }).toBe(1) + await page.getByRole('tab', { name: '输出' }).click() + await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) + await page.getByRole('complementary', { name: '记录详情' }) + .getByRole('button', { name: '关闭详情' }).click() }, 60_000) it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => { diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 80d6f161ca..4224bce857 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -1 +1,57 @@ -- text: "Turn 1 Message {{duration}} #1 User NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. +{{duration}} Step 1 {{duration}} bash read×2 #2 Tool bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"} +{{duration}} #3 Tool read · {\"file_path\": \"nav-a.md\"} +{{duration}} #4 Tool read · {\"file_path\": \"nav-b.md\"} +{{duration}} Step 2 {{duration}} #5 Message FIRST_DONE 349 56 51 +{{duration}} Turn 2 Message {{duration}} #6 User Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. +{{duration}} Step 1 {{duration}} #7 Message ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ``` 141 36 16 +{{duration}}" +- toolbar "轨迹工具栏": + - text: 轨迹 + - strong: "8" + - text: 条记录 + - strong: "2" + - text: 轮 + - strong: "3" + - text: 次工具 + - button "收起记录" +- table: + - rowgroup: + - row "# 事件 内容": + - columnheader "#" + - columnheader "事件" + - columnheader "内容" + - rowgroup: + - 'row "记录 1,USER,NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."': + - cell "#1" + - cell "USER" + - 'cell "NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."' + - row "记录 2,ASSISTANT,请求调用 bash、read×2": + - cell "#2" + - cell "Step 1 ASSISTANT": S1 ASSISTANT + - cell "请求调用 bash、read×2" + - 'row "记录 3,TOOL,bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}" [selected]': + - cell "#3" + - cell "TOOL" + - 'cell "bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}→NAVIGATION_OK"' + - 'row "记录 4,TOOL,read · {\"file_path\": \"nav-a.md\"}"': + - cell "#4" + - cell "TOOL" + - 'cell "read · {\"file_path\": \"nav-a.md\"}→{{cwd}}/nav-a.md file 1: # alpha nav (End of file - total 1 lines) "' + - 'row "记录 5,TOOL,read · {\"file_path\": \"nav-b.md\"}"': + - cell "#5" + - cell "TOOL" + - 'cell "read · {\"file_path\": \"nav-b.md\"}→{{cwd}}/nav-b.md file 1: # beta nav (End of file - total 1 lines) "' + - row "记录 6,ASSISTANT,FIRST_DONE": + - cell "#6" + - cell "Step 2 ASSISTANT": S2 ASSISTANT + - cell "FIRST_DONE" + - 'row "记录 7,USER,Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."': + - cell "#7" + - cell "USER" + - 'cell "Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."' + - 'row "记录 8,ASSISTANT,## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"': + - cell "#8" + - cell "Step 1 ASSISTANT": S1 ASSISTANT + - 'cell "## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"' +- complementary "记录详情": + - text: "TOOL 记录 #3 Turn 1 · Step 1" + - button "关闭详情" + - tablist "记录详情": + - tab "概览" + - tab "输入" + - tab "输出" [selected] + - tab "计时" + - tabpanel "输出": NAVIGATION_OK diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b1300a192c..15b3beebb8 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -28,7 +28,7 @@ export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, + AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 49ae8634ec..609c2830bc 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -3,7 +3,7 @@ // substructures keep their references (the React.memo premise). callId/approvalId stay plain // string here (narrow to real brands when convenient). -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' @@ -50,6 +50,16 @@ export interface UserMessageNode { source: unknown } +/** Recorded boundaries used to derive assistant latency and throughput. */ +export interface AssistantTiming { + /** Matching step/start timestamp, or null when it is outside the current event window. */ + stepStartTime: number | null + /** First non-empty text/reasoning/tool delta timestamp, or null when no token delta was recorded. */ + firstTokenTime: number | null + /** Final assistant/message timestamp. */ + completedTime: number +} + /** A finalized (or interruption-frozen) assistant message. */ export interface AssistantMessageNode { kind: 'assistant' @@ -60,6 +70,8 @@ export interface AssistantMessageNode { step: number blocks: readonly AssistantBlock[] usage?: unknown + /** Timing derived from the recorded step/chunk/message event sequence. */ + timing?: AssistantTiming /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker. * Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */ interrupted?: true @@ -216,6 +228,8 @@ export interface ConversationSnapshot { * unrelated snapshot swaps (memo premise, same regime as `nodes`). */ codeDispatches: ReadonlyMap + /** Model-visible tool schema captured for each recorded call id. */ + callSchemas?: ReadonlyMap pending: readonly PendingInteraction[] /** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */ queue: readonly QueuedMessage[] diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 0f40d9bf2a..9fc302398a 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -9,7 +9,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' // browser bundle cannot resolve; surface.ts has no Node dependencies. import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' -import type { ConversationNode } from './conversation.ts' +import type { AssistantTiming, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' /** In-window tool/call index entry (result-card backfill + runningCalls material). */ @@ -37,6 +37,7 @@ function materializeNode( event: SessionEvent, callIndex: ReadonlyMap, resultView: ToolResultView | null, + assistantTiming?: AssistantTiming, ): ConversationNode { switch (event.type) { case 'user/message': @@ -58,6 +59,7 @@ function materializeNode( 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, + ...(assistantTiming !== undefined ? { timing: assistantTiming } : {}), } case 'steering/message': return { @@ -177,7 +179,12 @@ export class FoldAdapter { const event = this.padded[seq] /* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */ if (event === undefined) continue - const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null) + const node = materializeNode( + event, + this.callIdx, + this.resultViews.get(seq) ?? null, + event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, + ) this.nodeCache.set(seq, node) out.push(node) } @@ -196,6 +203,33 @@ export class FoldAdapter { return seqs } + private assistantTiming(event: SessionEvent<'assistant/message'>): AssistantTiming { + let stepStartTime: number | null = null + let firstTokenTime: number | null = null + for (let i = this.baseSeq; i < this.padded.length; i++) { + const candidate = this.padded[i] + if (candidate === undefined || candidate.seq > event.seq) break + if ( + candidate.type === 'step/start' + && candidate.data.turn === event.data.turn + && candidate.data.step === event.data.step + ) { + stepStartTime = candidate.time + continue + } + if ( + firstTokenTime === null + && candidate.type === 'assistant/chunk' + && candidate.data.turn === event.data.turn + && candidate.data.step === event.data.step + && isTokenDelta(candidate.data.chunk) + ) { + firstTokenTime = candidate.time + } + } + return { stepStartTime, firstTokenTime, completedTime: event.time } + } + private indexCall(event: SessionEvent, view?: ToolEventView): void { if (event.type === 'tool/result') { if (view?.for === 'result') this.resultViews.set(event.seq, view.view) @@ -211,3 +245,15 @@ export class FoldAdapter { // (window order puts the call before its result; cannot happen on the normal path). } } + +function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 75c55bc4bd..493ca6baa7 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,7 +1,7 @@ // Sessions remain resident after creation so they continue consuming mux frames off-screen. import type { Context } from 'cordis' -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, @@ -104,6 +104,12 @@ export class Session implements ObservableSnapshot { private codeDispatches = new Map() private dispatchesRev = 0 private dispatchesCache: { rev: number; value: ReadonlyMap } | null = null + /** Schemas in force for the next tool/call, updated by request/header. */ + private activeToolSchemas = new Map() + /** Call-time schema snapshots keyed by native or code-dispatch call id. */ + private callSchemas = new Map() + private callSchemasRev = 0 + private callSchemasCache: { rev: number; value: ReadonlyMap } | null = null private running = false /** * Sticky send marker, private input of the composerPhase derivation: set @@ -611,6 +617,7 @@ export class Session implements ObservableSnapshot { argsRaw: JSON.stringify(data.arguments), turn: 0, step: 0, time: event.time, callView: null, } + this.captureCallSchema(data.subCallId, data.name) const siblings = this.codeDispatches.get(data.parentCallId) ?? [] this.codeDispatches.set(data.parentCallId, [...siblings, running]) this.dispatchesRev++ @@ -630,6 +637,7 @@ export class Session implements ObservableSnapshot { content: ContentBlock[] } const siblings = this.codeDispatches.get(data.parentCallId) ?? [] + this.captureCallSchema(data.subCallId, data.name) const at = siblings.findIndex(sub => sub.callId === data.subCallId) const started = at === -1 ? undefined : siblings[at] const settled: CodeSubCall = { @@ -651,6 +659,12 @@ export class Session implements ObservableSnapshot { return } switch (event.type) { + case 'request/header': { + this.activeToolSchemas = new Map( + (event.data.header.tools ?? []).map(schema => [schema.name, schema]), + ) + return + } case 'assistant/chunk': { const { turn, step, chunk } = event.data if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) { @@ -666,6 +680,7 @@ export class Session implements ObservableSnapshot { return } case 'tool/call': { + this.captureCallSchema(String(event.data.callId), event.data.name) 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, time: event.time, @@ -720,6 +735,15 @@ export class Session implements ObservableSnapshot { } } + /** Preserve the schema active when one call starts. */ + private captureCallSchema(callId: string, name: string): void { + if (this.callSchemas.has(callId)) return + const schema = this.activeToolSchemas.get(name) + if (schema === undefined) return + this.callSchemas.set(callId, schema) + this.callSchemasRev++ + } + /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps * paging/stitching consistent, and makes the live freeze and the history replay converge on the * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */ @@ -731,6 +755,9 @@ export class Session implements ObservableSnapshot { this.frozenRev++ this.codeDispatches = new Map() this.dispatchesRev++ + this.activeToolSchemas = new Map() + this.callSchemas = new Map() + this.callSchemasRev++ for (let i = 0; i < this.events.length; i++) { const event = this.events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -766,6 +793,9 @@ export class Session implements ObservableSnapshot { if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) { this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) } } + if (this.callSchemasCache === null || this.callSchemasCache.rev !== this.callSchemasRev) { + this.callSchemasCache = { rev: this.callSchemasRev, value: new Map(this.callSchemas) } + } if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) } } @@ -778,6 +808,7 @@ export class Session implements ObservableSnapshot { runningCalls: this.callsCache.value, pending: this.pendingCache.value, codeDispatches: this.dispatchesCache.value, + callSchemas: this.callSchemasCache.value, queue: this.queueCache.value, running: this.running, composerPhase: derivePhase( diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c8d5be336d..76ac8a8db3 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -72,8 +72,8 @@ export function apply(ctx: Context): void { }, }), 'ui-conversation: input standard-kit provider') - // Resident current-session-optional shell. It owns the stable Hero/composer - // frame while strict session slots fill only their session-bound regions. + // Resident current-session-optional shell. It constructs the stable + // composer frame; the strict session child decides whether Chat mounts it. slots.register({ name: 'conversation', children: { @@ -106,8 +106,8 @@ export function apply(ctx: Context): void { }), }, ConversationRoot) - // The strict session subtree owns only per-session store and view content; - // the resident parent keeps Hero and composer layout identity stable. + // The strict session subtree owns the per-session store and view content; + // the resident parent supplies the composer node through owner props. slots.register({ name: 'conversation.session', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1e620f0905..b2b193bc40 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -107,6 +107,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Owner share of the strict session content seat. */ export interface ConversationSessionOwnerProps { + /** Composer chain assembled by the resident parent; only Chat mounts it. */ + composer: ReactNode } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 5f9e91a049..51e5fe9d33 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -74,6 +74,11 @@ export function ConversationRoot({ {inputBar} ) + const composer = renderSlotChain( + 'conversation.composer', + { interactions: pending }, + { fallback: composerBar, overlay: true }, + ) return (
@@ -81,12 +86,9 @@ export function ConversationRoot({ renders no chrome while blank but owns the draft-persistence mirror bind — unmounting it in the hero would lose pre-first-send text on a refresh or scope rebuild. */} - {sessionId !== undefined && renderSlot('conversation.session', {})} - {renderSlotChain( - 'conversation.composer', - { interactions: pending }, - { fallback: composerBar, overlay: true }, - )} + {sessionId !== undefined + ? renderSlot('conversation.session', { composer }) + : composer}
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 515bfe1f93..26615905a1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,6 +1,6 @@ /** Strict per-session conversation content: header, view ring, and chat store bindings. */ -import { useEffect, useSyncExternalStore } from 'react' +import { Fragment, useEffect, useSyncExternalStore } from 'react' import clsx from 'clsx' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationSession({ sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, - renderSlot, views, bindDraftMirror, open, + renderSlot, views, bindDraftMirror, open, composer, }: ConversationSessionProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -45,11 +45,11 @@ export function ConversationSession({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [inputActions]) - if (blank && composerPhase === 'blank') return null + const blankHero = blank && composerPhase === 'blank' return ( <> -
+ {!blankHero &&
)} -
-
+
} + {!blankHero &&
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} -
+ } + {(blankHero || active?.id === 'chat') && {composer}} ) } diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index b805bb178a..fef81b6e9f 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -54,12 +54,12 @@ Handle component; the hit strip stays wider than the pill. */ .handle { position: absolute; + z-index: 6; top: 0; bottom: 0; width: 8px; margin-left: -4px; cursor: col-resize; - z-index: 2; touch-action: none; /* Rides the same curve as the tracks so the pill stays on the moving border during collapse/expand; paused while dragging (frame rule). */ diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 9ce2bc8676..dfb9fa8739 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -22,8 +22,12 @@ "dependencies": { "@shikijs/langs": "^4.3.1", "clsx": "^2.0.0", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-gfm": "^3.1.0", + "micromark-extension-gfm": "^3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-json-view-lite": "^2.5.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "shiki": "^4.3.1" diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css new file mode 100644 index 0000000000..34b690a0f0 --- /dev/null +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -0,0 +1,221 @@ +.root { + --json-tree-property: #881391; + --json-tree-string: #c41a16; + --json-tree-number: #1c00cf; + --json-tree-keyword: #1c00cf; + --json-tree-punctuation: #202124; + --json-tree-icon: #5f6368; + --json-tree-hover: rgb(60 64 67 / 4%); + + min-width: 0; + overflow: auto; + position: relative; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-layer-1); + font: 12px/16px var(--ds-font-family-code); + overscroll-behavior: contain; +} + +:global(body[data-ds-dark-theme]) .root { + --json-tree-property: #5db0d7; + --json-tree-string: #f28b82; + --json-tree-number: #99c8ff; + --json-tree-keyword: #99c8ff; + --json-tree-punctuation: #e8eaed; + --json-tree-icon: #9aa0a6; + --json-tree-hover: rgb(232 234 237 / 5%); +} + +.container { + box-sizing: border-box; + width: max-content; + min-width: 100%; + margin: 0; + padding: 6px 8px 8px; + white-space: pre; +} + +.expandedTopLevel { + box-sizing: border-box; + width: max-content; + min-width: 100%; + padding: 6px 8px 8px 14px; +} + +.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover), +.expandedTopLevel:has(> .topLevelBracket[data-json-root-row][data-json-copy-active]) { + background: var(--json-tree-hover); +} + +.expandedTopLevelContainer { + padding: 0 0 0 calc(2ch - 12px); +} + +.row.topLevelBracket { + margin-left: 0; + padding-left: 0; +} + +.children { + margin: 0; + padding: 0 0 0 4px; + list-style: none; +} + +.row { + position: relative; + box-sizing: border-box; + min-width: 100%; + min-height: 16px; + margin: 0; + padding: 0 0 0 12px; + list-style: none; +} + +.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after, +.row:not(.topLevelBracket)[data-json-copy-active]::after, +.row:has(> .expander:focus-visible)::after { + position: absolute; + z-index: 0; + top: 0; + right: -100vw; + left: -100vw; + height: 16px; + background: var(--json-tree-hover); + content: ''; + pointer-events: none; +} + +.row > span:not(.expander) { + position: relative; + z-index: 1; +} + +.label { + margin-right: 3px; + color: var(--json-tree-property); + font-weight: 400; +} + +.clickableLabel { + cursor: pointer; +} + +.stringValue { + color: var(--json-tree-string); +} + +.numberValue { + color: var(--json-tree-number); +} + +.keywordValue { + color: var(--json-tree-keyword); +} + +.otherValue { + color: var(--dsw-alias-label-secondary); +} + +.punctuation { + color: var(--json-tree-punctuation); +} + +.preview { + color: var(--json-tree-punctuation); +} + +.previewProperty { + color: var(--json-tree-punctuation); +} + +.previewEllipsis { + color: var(--dsw-alias-label-tertiary); +} + +.copyAnchor { + position: absolute; + z-index: 3; + display: inline-flex; +} + +.copyButton { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 16px; + margin: 0; + padding: 0; + border: 0; + border-radius: 3px; + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-layer-1); + box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1); + cursor: pointer; +} + +.copyButton:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.copyButton:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: -1px; +} + +.copyButton[data-state='failed'] { + color: var(--dsw-alias-state-error-primary); +} + +.expander { + position: absolute; + z-index: 2; + top: 0; + left: 0; + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 8px; + height: 16px; + margin: 0; + color: var(--json-tree-icon); + cursor: pointer; + user-select: none; +} + +.expander::before { + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 6px solid currentColor; + content: ''; + transform: scale(0.75); + transform-origin: center; +} + +.collapseIcon::before { + transform: rotate(90deg) scale(0.75); +} + +.expander:hover { + color: var(--dsw-alias-label-primary); +} + +.expander:focus-visible { + outline: none; +} + +.collapsedContent { + margin: 0 1px; + color: var(--json-tree-punctuation); + cursor: pointer; +} + +.collapsedContent::after { + content: '…'; +} diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx new file mode 100644 index 0000000000..324c7abf1a --- /dev/null +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -0,0 +1,390 @@ +import clsx from 'clsx' +import { collapseAllNested, JsonView } from 'react-json-view-lite' +import { useEffect, useRef, useState } from 'react' +import type { MouseEvent as ReactMouseEvent, ReactNode, UIEvent as ReactUIEvent } from 'react' +import type { Props as LiteJsonViewProps } from 'react-json-view-lite' +import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx' +import { Menu } from './Menu.tsx' +import type { MenuEntry } from './Menu.tsx' +import css from './JsonTree.module.css' + +const OBJECT_PREVIEW_LIMIT = 4 +const ARRAY_PREVIEW_LIMIT = 5 +const PREVIEW_DEPTH_LIMIT = 2 +const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [ + { id: 'value', label: 'Copy value' }, + { id: 'json', label: 'Copy JSON' }, + { id: 'path', label: 'Copy property path' }, +] +const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [ + { id: 'prettyJson', label: 'Copy pretty JSON' }, + { id: 'json', label: 'Copy compact JSON' }, + { id: 'path', label: 'Copy property path' }, +] + +const TREE_STYLES: NonNullable = { + container: css.container!, + childFieldsContainer: css.children!, + basicChildStyle: css.row!, + label: css.label!, + clickableLabel: `${css.label!} ${css.clickableLabel!}`, + nullValue: css.keywordValue!, + undefinedValue: css.keywordValue!, + numberValue: css.numberValue!, + stringValue: css.stringValue!, + booleanValue: css.keywordValue!, + otherValue: css.otherValue!, + punctuation: css.punctuation!, + expandIcon: `${css.expander!} ${css.expandIcon!}`, + collapseIcon: `${css.expander!} ${css.collapseIcon!}`, + collapsedContent: css.collapsedContent!, + noQuotesForStringValues: false, + quotesForFieldNames: false, + stringifyStringValues: true, + ariaLables: { + collapseJson: 'Collapse JSON node', + expandJson: 'Expand JSON node', + }, +} + +const EXPANDED_TOP_LEVEL_TREE_STYLES: NonNullable = { + ...TREE_STYLES, + container: `${css.container!} ${css.expandedTopLevelContainer!}`, +} + +function previewPrimitive(value: unknown): ReactNode { + if (value === null) return null + if (typeof value === 'string') { + return {JSON.stringify(value)} + } + if (typeof value === 'number') { + return {String(value)} + } + if (typeof value === 'boolean') { + return {String(value)} + } + return {String(value)} +} + +function previewValue(value: unknown, depth: number): ReactNode { + if (typeof value !== 'object' || value === null) return previewPrimitive(value) + + const array = Array.isArray(value) + const entries = array + ? value.map((item, index) => [String(index), item] as const) + : Object.entries(value) + const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT + const visible = entries.slice(0, limit) + const open = array ? '[' : '{' + const close = array ? ']' : '}' + + return ( + <> + {open} + {depth >= PREVIEW_DEPTH_LIMIT + ? + : visible.map(([key, item], index) => ( + + {index > 0 && , } + {!array && ( + <> + {key} + : + + )} + {previewValue(item, depth + 1)} + + ))} + {depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && ( + {visible.length > 0 ? ', …' : '…'} + )} + {close} + + ) +} + +function renderExpandableValue(value: object): ReactNode { + return {previewValue(value, 0)} +} + +interface CopyTarget { + left: number + path: readonly (number | string)[] + side: 'bottom' | 'top' + top: number + value: unknown +} + +function fieldOf(row: HTMLElement): string | undefined { + const label = Array.from(row.children).find( + child => child instanceof HTMLElement && child.classList.contains(css.label!), + ) + const text = label?.textContent + return text === undefined || text === null ? undefined : text.slice(0, -1) +} + +function resolveRow(data: object | unknown[], row: HTMLElement, expandTopLevel: boolean): { + path: readonly (number | string)[] + value: unknown +} | undefined { + if (row.hasAttribute('data-json-root-row')) return { path: [], value: data } + + const lineage: HTMLElement[] = [] + let cursor: HTMLElement | null = row + while (cursor !== null) { + lineage.unshift(cursor) + const group: HTMLElement | null = cursor.parentElement + const parentRow: Element | null = group?.getAttribute('role') === 'group' + ? group.parentElement?.closest('[role="treeitem"]') ?? null + : null + cursor = parentRow instanceof HTMLElement ? parentRow : null + } + + let value: unknown = data + const path: (number | string)[] = [] + for (const item of expandTopLevel ? lineage : lineage.slice(1)) { + const field = fieldOf(item) + if (field === undefined) return undefined + if (Array.isArray(value)) { + const index = Number(field) + if (!Number.isInteger(index)) return undefined + path.push(index) + value = value[index] + } else if (typeof value === 'object' && value !== null) { + path.push(field) + value = (value as Record)[field] + } else { + return undefined + } + } + return { path, value } +} + +function formattedPath(path: readonly (number | string)[]): string { + return path.reduce((result, part) => { + if (typeof part === 'number') return `${result}[${String(part)}]` + return /^[A-Za-z_$][\w$]*$/.test(part) + ? `${result}.${part}` + : `${result}[${JSON.stringify(part)}]` + }, '$') +} + +function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string { + if (mode === 'path') return formattedPath(target.path) + if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2) + if (mode === 'json') return JSON.stringify(target.value) ?? String(target.value) + if (typeof target.value === 'string') return target.value + if (typeof target.value === 'object' && target.value !== null) { + return JSON.stringify(target.value, null, 2) + } + return JSON.stringify(target.value) ?? String(target.value) +} + +/** Props for the read-only, token-themed JSON tree. */ +export interface JsonTreeProps { + /** Parsed JSON object or array. */ + data: object | unknown[] + /** Accessible label for the tree. */ + label?: string + /** Optional positioning class owned by the caller. */ + className?: string | undefined + /** Whether JSON rows expose copy actions. */ + copyable?: boolean + /** Whether the top-level object or array is always expanded. */ + expandTopLevel?: boolean +} + +/** + * Render parsed JSON as a compact, keyboard-accessible inspector tree. + * @param props - Parsed data, accessible label, and display options. + * @returns A read-only JSON tree with an optionally fixed-open top level. + */ +export function JsonTree({ + data, + label = 'JSON', + className, + copyable = true, + expandTopLevel = true, +}: JsonTreeProps) { + const rootRef = useRef(null) + const activeRowRef = useRef() + const copyButtonRef = useRef(null) + const copyMenuOpenRef = useRef(false) + const resetTimer = useRef>() + const [copyTarget, setCopyTarget] = useState() + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle') + const [copyMenuOpen, setCopyMenuOpen] = useState(false) + + useEffect(() => () => { + if (resetTimer.current !== undefined) clearTimeout(resetTimer.current) + activeRowRef.current?.removeAttribute('data-json-copy-active') + }, []) + + const setActiveRow = (row: HTMLElement | undefined) => { + activeRowRef.current?.removeAttribute('data-json-copy-active') + activeRowRef.current = row + row?.setAttribute('data-json-copy-active', '') + } + + const positionCopyButton = (row: HTMLElement, target: { + path: readonly (number | string)[] + value: unknown + }) => { + const root = rootRef.current + if (root === null) return + const rootRect = root.getBoundingClientRect() + const rowRect = row.getBoundingClientRect() + setCopyTarget({ + left: root.scrollLeft + root.clientWidth - 26, + path: target.path, + side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom', + top: root.scrollTop + rowRect.top - rootRect.top, + value: target.value, + }) + } + + const clearCopyTarget = () => { + setActiveRow(undefined) + setCopyTarget(undefined) + setCopyState('idle') + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + } + + const handleMouseOver = (event: ReactMouseEvent) => { + if (!copyable || !(event.target instanceof Element)) return + if (copyMenuOpenRef.current) return + if (!event.currentTarget.contains(event.target)) return + if (event.target.closest('[data-json-copy-button]') !== null) return + const row = event.target.closest('[data-json-root-row], [role="treeitem"]') + if (row === null) { + clearCopyTarget() + return + } + if (activeRowRef.current === row) return + const resolved = resolveRow(data, row, expandTopLevel) + if (resolved === undefined) return + setActiveRow(row) + setCopyState('idle') + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + positionCopyButton(row, resolved) + } + + const handleScroll = (event: ReactUIEvent) => { + if (event.currentTarget !== event.target) return + const row = activeRowRef.current + if (row === undefined) return + const resolved = resolveRow(data, row, expandTopLevel) + if (resolved !== undefined) positionCopyButton(row, resolved) + } + + const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => { + if (copyTarget === undefined) return + try { + await navigator.clipboard.writeText(copyText(copyTarget, mode)) + setCopyState('copied') + } catch { + setCopyState('failed') + } + if (resetTimer.current !== undefined) clearTimeout(resetTimer.current) + resetTimer.current = setTimeout(() => setCopyState('idle'), 1_500) + } + + const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null + const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value' + const copyTitle = copyState === 'copied' + ? 'Copied' + : copyState === 'failed' + ? 'Copy failed' + : copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value' + + return ( +
{ + if (!copyMenuOpenRef.current) clearCopyTarget() + }} + onScroll={handleScroll} + > + {expandTopLevel + ? ( +
+
+ {Array.isArray(data) ? '[' : '{'} +
+ +
+ {Array.isArray(data) ? ']' : '}'} +
+
+ ) + : ( + + )} + {copyTarget !== undefined && ( + + void copy(defaultCopyMode)} + onContextMenu={(event) => { + event.preventDefault() + event.stopPropagation() + copyMenuOpenRef.current = true + setCopyMenuOpen(true) + }} + > + {copyState === 'copied' + ? + : } + + )} + items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS} + onSelect={(id) => { + if (id === 'value' || id === 'json' || id === 'prettyJson' || id === 'path') { + void copy(id) + } + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + }} + onClose={clearCopyTarget} + getAnchorRect={() => copyButtonRef.current?.getBoundingClientRect() ?? null} + /> + + )} +
+ ) +} diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 6825731275..e6a59ea84a 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -77,6 +77,37 @@ background: var(--dsw-alias-interactive-bg-hover); } +.list.compactList, +.submenu.compactList { + min-width: 164px; + padding: 2px; + border-radius: 7px; +} + +.compactList .item { + min-height: 26px; + gap: 6px; + padding: 3px 7px; + border-radius: 5px; + font-size: 12px; + line-height: 18px; +} + +.compactList .itemIcon { + width: 14px; + height: 14px; +} + +.compactList .separator { + margin: 2px; +} + +.compactList .label { + padding: 4px 7px; + font-size: 11px; + line-height: 16px; +} + .item:disabled { color: var(--dsw-alias-label-dimmed); cursor: not-allowed; diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 9abb6a3bb2..e99722875b 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -66,6 +66,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { * keeps the pure-CSS in-place behavior. * @param props.closeOnPointerLeave - close the list when the pointer leaves * it (default false keeps it open until outside click/Escape/selection). + * @param props.compact - use reduced menu typography and spacing. * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the * Menu's own wrapper span. Required when the wrapper isn't itself laid out at @@ -74,7 +75,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { * scroll/resize; return null to skip placement for that frame. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] @@ -85,6 +86,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align side?: 'bottom' | 'top' portal?: boolean closeOnPointerLeave?: boolean + compact?: boolean getAnchorRect?: () => DOMRect | null className?: string }) { @@ -149,7 +151,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align const list = open && (!portal || fixedPos !== null) && (
{ onClose() } : undefined} @@ -196,7 +198,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align {entry.id === selectedId && } {subOpen && entry.submenu !== undefined && ( -
+
{entry.submenu.map(sub => ( + ) + : ( +
+ + {`Block #${index + 1} ${block.type}`} + +
+ )} + {block.imageSrc !== undefined + ? + :
{block.content}
} + + ))} +
+ ) +} + +function PanelImage({ + block, + preview = false, +}: { + block: TrajectorySourceBlock + preview?: boolean +}) { + if (block.imageSrc === undefined) return null + return ( + + {block.imageAlt + + ) +} + +function MessageImages({ + blocks, + preview, +}: { + blocks: readonly TrajectorySourceBlock[] | undefined + preview: boolean +}) { + const images = blocks?.filter(block => block.imageSrc !== undefined) ?? [] + if (images.length === 0) return null + return ( +
+ {images.map((block, index) => )} +
+ ) +} + +function AssistantToolCalls({ + blocks, + preview, + onOpenCall, +}: { + blocks: readonly TrajectorySourceBlock[] | undefined + preview: boolean + onOpenCall(callId: string): void +}) { + const calls = blocks?.filter(block => block.type === 'tool-call') ?? [] + if (calls.length === 0) return null + return ( +
    + {calls.map((call, index) => ( +
  • + +
  • + ))} +
+ ) +} + +function ToolOutputBlocks({ + blocks, + preview, +}: { + blocks: readonly TrajectorySourceBlock[] + preview: boolean +}) { + return ( +
+ {blocks.map((block, index) => ( + block.imageSrc !== undefined + ? + : block.content !== '' + ?
{block.content}
+ : null + ))} +
+ ) +} + +function MarkdownRecordContent({ + record, + rendered, + preview = false, + thinkingExpanded, + onThinkingExpandedChange, + onOpenCall, +}: { + record: TableRecord + rendered: boolean + preview?: boolean + thinkingExpanded: boolean + onThinkingExpandedChange(expanded: boolean): void + onOpenCall(callId: string): void +}) { + if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) { + return + } + if (record.cell.kind === 'message' && record.cell.thinkingDetail) { + if (!rendered) { + const source = [ + record.cell.thinkingDetail, + record.cell.outputDetail, + ].filter((value): value is string => value !== undefined && value !== '').join('\n\n') + return + } + return ( +
+
+ + {thinkingExpanded && ( + + )} +
+ {record.cell.outputDetail && ( +
+ +
+ )} + + +
+ ) + } + const source = markdownSource(record) + const hasImages = record.cell.sourceBlocks?.some(block => block.imageSrc !== undefined) === true + const hasToolCalls = record.cell.kind === 'message' + && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true + if (!source && !hasImages && !hasToolCalls) { + const emptyLabel = isToolCallOnly(record.cell) + ? 'Tool call only' + : record.cell.text || 'No content' + return

{emptyLabel}

+ } + if (!rendered || (!hasImages && !hasToolCalls)) { + return + } + return ( +
+ {source && } + {record.cell.kind === 'message' && ( + + )} + +
+ ) +} + +function RecordTiming({ record }: { record: TableRecord }) { + return record.cell.kind === 'message' && record.cell.assistantMetrics !== undefined + ? + : ( +
+
Started
+
Duration
{formatElapsedSeconds(record.cell.timeSeconds)}
+
Timing source
{record.cell.timeSeconds === null ? 'Not available' : 'Session timestamps'}
+
+ ) +} + +function RecordPayload({ + record, + direction, + preview = false, +}: { + record: TableRecord + direction: 'input' | 'output' + preview?: boolean +}) { + const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail + const missing = direction === 'input' + ? 'No payload captured' + : 'No result captured' + if (!value) return

{missing}

+ + if (direction === 'output' && record.cell.outputBlocks && record.cell.outputBlocks.length > 0) { + return ( + + ) + } + + const markdown = ( + direction === 'input' && record.cell.kind === 'user' + ) || ( + direction === 'output' && record.cell.kind === 'message' + ) + if (markdown) { + return ( +
+ +
+ ) + } + const json = parseJsonContainer(value) + if (json !== undefined) { + return ( + + ) + } + return ( +
 value !== undefined).join(' ')}
+    >
+      {value}
+    
+ ) +} + +function RecordSchema({ + record, + preview = false, +}: { + record: TableRecord + preview?: boolean +}) { + if (!record.cell.schemaDetail) { + return

Schema unavailable

+ } + const schema = parseToolSchema(record.cell.schemaDetail) + if (schema !== undefined) { + return ( +
+
+

{schema.name}

+

{schema.description}

+
+
+

Parameters

+ +
+
+ ) + } + return ( +
+      {record.cell.schemaDetail}
+    
+ ) +} + +interface ParsedToolSchema { + name: string + description: string + parameters: object +} + +function parseToolSchema(value: string): ParsedToolSchema | undefined { + try { + const parsed: unknown = JSON.parse(value) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined + const schema = parsed as Record + if ( + typeof schema.name !== 'string' + || typeof schema.description !== 'string' + || typeof schema.parameters !== 'object' + || schema.parameters === null + || Array.isArray(schema.parameters) + ) return undefined + return { + name: schema.name, + description: schema.description, + parameters: schema.parameters, + } + } catch { + return undefined + } +} + +function parseJsonContainer(value: string): object | undefined { + try { + const parsed: unknown = JSON.parse(value) + return typeof parsed === 'object' && parsed !== null ? parsed : undefined + } catch { + return undefined + } +} + +function OverviewSection({ + label, + onOpen, + children, +}: { + label: string + onOpen(): void + children: ReactNode +}) { + return ( +
+

+ +

+
{children}
+
+ ) +} + +/** + * Render trajectory events as a dense ledger with turn and step separators. + * @param props - Grouped trajectory data and whole-ledger fold state. + * @returns The ledger and an optional local record inspector. + */ +export function TrajectoryTable({ + turns, + collapsedTurns, + onToggleTurn, + collapsedAssistants, + onToggleAssistant, +}: TrajectoryTableProps) { + const [selectedIndex, setSelectedIndex] = useState(null) + const [activeTab, setActiveTab] = useState('overview') + const [thinkingExpanded, setThinkingExpanded] = useState(true) + const [detailsWidth, setDetailsWidth] = useState(null) + const [toolRequestOffset, setToolRequestOffset] = useState(null) + const detailsResizeDrag = useRef(null) + const tabHistory = useRef>(new Set(['overview'])) + const allRecords = flattenRecords(turns) + const turnRecords = collapseTurnRecords(allRecords, collapsedTurns) + const records = collapseAssistantRecords(turnRecords, collapsedAssistants) + const selected = allRecords.find(record => record.cell.index === selectedIndex) + const selectedState = selected === undefined ? undefined : stateOf(selected) + const selectedTabs = selected === undefined ? [] : detailTabs(selected) + const selectedParents: ParentRecords = selected === undefined + ? {} + : parentRecords(allRecords, selected) + const hasSelectedParents = selectedParents.message !== undefined + || selectedParents.tool !== undefined + const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null + ? undefined + : { + '--trajectory-tool-request-width': `calc(58cqw - ${toolRequestOffset}px)`, + } + + const activateTab = (tab: DetailTab) => { + tabHistory.current.delete(tab) + tabHistory.current.add(tab) + setActiveTab(tab) + } + + const selectRecord = (index: number) => { + const record = allRecords.find(candidate => candidate.cell.index === index) + setSelectedIndex(index) + if (record === undefined) return + const available = new Set(detailTabs(record).map(tab => tab.id)) + const recent = [...tabHistory.current].reverse().find(tab => available.has(tab)) + setActiveTab(recent ?? 'overview') + } + + const openRecordSummary = (target: TableRecord) => { + const targetAt = allRecords.findIndex(record => record.cell.index === target.cell.index) + if (collapsedTurns.has(target.turn)) onToggleTurn(target.turn) + if (target.cell.kind === 'tool' || target.cell.kind === 'subtool') { + for (let i = targetAt - 1; i >= 0; i--) { + const candidate = allRecords[i] + if (candidate === undefined || candidate.turn !== target.turn) break + if (candidate.cell.kind !== 'message') continue + if (collapsedAssistants.has(candidate.cell.index)) onToggleAssistant(candidate.cell.index) + break + } + } + setSelectedIndex(target.cell.index) + activateTab('overview') + } + + const openCallSummary = (callId: string) => { + const target = allRecords.find(record => record.cell.callId === callId) + if (target !== undefined) openRecordSummary(target) + } + + return ( +
+
+ + + + + + + {records.map((record) => { + const displayText = recordDisplayText(record.cell) + const isCollapsedSummary = record.collapsedSummary !== undefined + return ( + { + if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn) + else onToggleAssistant(record.cell.index) + } + : () => { selectRecord(record.cell.index) }} + onDoubleClick={(event) => { + if (isCollapsedSummary) return + if (collapsedTurns.has(record.turn)) { + event.preventDefault() + onToggleTurn(record.turn) + return + } + if ( + record.cell.kind === 'message' + && assistantToolCalls(allRecords, record.cell.index).length > 0 + ) { + event.preventDefault() + onToggleAssistant(record.cell.index) + return + } + if (!record.turnStart) return + if (allRecords.filter(candidate => candidate.turn === record.turn).length <= 1) return + event.preventDefault() + onToggleTurn(record.turn) + }} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + if (isCollapsedSummary) { + if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn) + else onToggleAssistant(record.cell.index) + return + } + selectRecord(record.cell.index) + }} + > + + + + ) + })} + +
+ {selected?.turn === record.turn && ( + + {record.collapsedSummary !== undefined + ? ( + + + {record.collapsedSummary} + + ) + : ( + + + {isToolCallOnly(record.cell) ? null : displayText || '—'} + + {record.cell.result !== undefined && ( + + + {record.cell.result} + + )} + + )} +
+
+ {selected !== undefined && selectedState !== undefined && ( + + )} +
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css new file mode 100644 index 0000000000..d5d81521c2 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css @@ -0,0 +1,85 @@ +.root { + position: sticky; + top: 0; + z-index: 4; + box-sizing: border-box; + width: 100%; + height: var(--dsh-trajectory-toolbar-height); + border-bottom: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-1); +} + +.inner { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 0 14px 0 16px; +} + +.summary { + display: flex; + align-items: center; + min-width: 0; + gap: 10px; +} + +.title { + flex: none; + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xs-strong-13); +} + +.actions { + display: flex; + flex: none; + align-items: center; + gap: 2px; +} + +.action { + display: inline-flex; + flex: none; + align-items: center; + box-sizing: border-box; + height: 26px; + padding: 0 7px; + gap: 6px; + border: 0; + border-radius: 4px; + color: var(--dsw-alias-label-tertiary); + background: transparent; + cursor: pointer; + font: var(--dsw-font-xs-13); + transition: + color 120ms var(--ds-ease-in-out), + background-color 120ms var(--ds-ease-in-out); +} + +.action:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.action:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.action:disabled { + color: var(--dsw-alias-label-dimmed); + cursor: not-allowed; +} + +.actionIcon { + color: var(--dsw-alias-label-tertiary); + font: 13px/13px var(--ds-font-family-code); +} + +@media (max-width: 720px) { + .summary { + gap: 7px; + } +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx new file mode 100644 index 0000000000..f063377a00 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx @@ -0,0 +1,66 @@ +/** Trajectory toolbar: view identity, record totals, and the ledger fold control. */ + +import css from './TrajectoryToolbar.module.css' + +export interface TrajectoryToolbarProps { + /** Number of turns containing more than one row. */ + collapsibleTurns: number + /** Whether every collapsible turn is currently folded. */ + allTurnsCollapsed: boolean + /** Fold or expand every collapsible turn. */ + onToggleAllTurns(): void + /** Number of assistant messages followed by tool calls. */ + collapsibleAssistants: number + /** Whether every collapsible assistant's tool calls are currently folded. */ + allAssistantsCollapsed: boolean + /** Fold or expand tool calls under every collapsible assistant. */ + onToggleAllAssistants(): void +} + +/** + * Render the sticky trajectory toolbar. + * @param props - rendered counts and whole-list fold state. + * @returns the toolbar element. + */ +export function TrajectoryToolbar({ + collapsibleTurns, + allTurnsCollapsed, + onToggleAllTurns, + collapsibleAssistants, + allAssistantsCollapsed, + onToggleAllAssistants, +}: TrajectoryToolbarProps) { + return ( +
+
+
+ Trajectory +
+
+ + +
+
+
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 3d417b085e..ea1bb8a1e7 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,41 +1,110 @@ -// TrajectoryView: sticky Turn sections with Message/Step groups and step cells. +/** Trajectory view: compact summary over a turn-aware event ledger. */ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { TrajectoryCell } from './TrajectoryCell.tsx' -import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx' -import { TrajectoryTurn } from './TrajectoryTurn.tsx' +import { TrajectoryTable } from './TrajectoryTable.tsx' +import { TrajectoryToolbar } from './TrajectoryToolbar.tsx' import { deriveTrajectoryLayout } from './layout.ts' import css from './views.module.css' export function TrajectoryView({ useSession }: ConvViewProps) { + const [collapsedTurns, setCollapsedTurns] = useState>(() => new Set()) + const [collapsedAssistants, setCollapsedAssistants] = useState>(() => new Set()) const nodes = useSession((s) => s.nodes) const partial = useSession((s) => s.partial) const runningCalls = useSession((s) => s.runningCalls) + const callSchemas = useSession((s) => s.callSchemas) const codeDispatches = useSession((s) => s.codeDispatches) const turns = useMemo( - () => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }), - [nodes, partial, runningCalls, codeDispatches], + () => deriveTrajectoryLayout({ nodes, partial, runningCalls, callSchemas, codeDispatches }), + [nodes, partial, runningCalls, callSchemas, codeDispatches], ) - if (turns.length === 0) { - return

暂无轨迹数据

+ const collapsibleTurnIds = useMemo( + () => turns + .filter(turn => turn.groups.reduce((count, group) => count + group.cells.length, 0) > 1) + .map(turn => turn.turn), + [turns], + ) + const allTurnsCollapsed = collapsibleTurnIds.length > 0 + && collapsibleTurnIds.every(turn => collapsedTurns.has(turn)) + const collapsibleAssistantIds = useMemo(() => { + const ids: number[] = [] + for (const turn of turns) { + const cells = turn.groups.flatMap(group => group.cells) + for (let i = 0; i < cells.length; i++) { + const cell = cells[i] + if (cell?.kind !== 'message') continue + const next = cells[i + 1] + if (next?.kind === 'tool' || next?.kind === 'subtool') ids.push(cell.index) + } + } + return ids + }, [turns]) + const allAssistantsCollapsed = collapsibleAssistantIds.length > 0 + && collapsibleAssistantIds.every(index => collapsedAssistants.has(index)) + + const toggleTurn = (turn: number) => { + setCollapsedTurns((current) => { + const next = new Set(current) + if (next.has(turn)) next.delete(turn) + else next.add(turn) + return next + }) } + + const toggleAllTurns = () => { + setCollapsedTurns((current) => { + const next = new Set(current) + if (allTurnsCollapsed) { + for (const turn of collapsibleTurnIds) next.delete(turn) + } else { + for (const turn of collapsibleTurnIds) next.add(turn) + } + return next + }) + } + + const toggleAssistant = (index: number) => { + setCollapsedAssistants((current) => { + const next = new Set(current) + if (next.has(index)) next.delete(index) + else next.add(index) + return next + }) + } + + const toggleAllAssistants = () => { + setCollapsedAssistants((current) => { + const next = new Set(current) + if (allAssistantsCollapsed) { + for (const index of collapsibleAssistantIds) next.delete(index) + } else { + for (const index of collapsibleAssistantIds) next.add(index) + } + return next + }) + } + return (
- {turns.map((turn) => ( - - {turn.groups.flatMap((group) => [ - , - ...group.cells.map((cell) => ( - - )), - ])} - - ))} + + {turns.length === 0 &&

No trajectory events

} + {turns.length > 0 && ( + + )}
) } diff --git a/packages/client/ui-trajectory/src/client/WaterfallView.tsx b/packages/client/ui-trajectory/src/client/WaterfallView.tsx index ad81845fb9..6776680882 100644 --- a/packages/client/ui-trajectory/src/client/WaterfallView.tsx +++ b/packages/client/ui-trajectory/src/client/WaterfallView.tsx @@ -28,7 +28,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa 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

暂无瀑布数据

+ if (spans.length === 0) return

No timing data

return ( <> diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 37c86f6eb4..54d025ab8f 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -3,12 +3,16 @@ * own-duration times, in-flight partial/runningCalls, and group descriptions. */ import type { + AssistantBlock, AssistantMessageNode, CodeSubCall, ConversationSnapshot, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' -import type { TrajectoryCellProps } from './TrajectoryCell.tsx' +import type { + TrajectoryCellProps, + TrajectorySourceBlock, +} from './trajectory-record.ts' /** One Message or Step group inside a turn. */ export interface TrajectoryGroupModel { @@ -28,6 +32,7 @@ export interface TrajectoryLayoutInput { nodes: ConversationSnapshot['nodes'] partial: ConversationSnapshot['partial'] runningCalls: ConversationSnapshot['runningCalls'] + callSchemas?: ConversationSnapshot['callSchemas'] /** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */ codeDispatches: ConversationSnapshot['codeDispatches'] } @@ -52,8 +57,17 @@ interface LaidCell { * @returns turns ordered by first appearance. */ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] { - const { nodes, partial, runningCalls, codeDispatches } = input + const { nodes, partial, runningCalls, callSchemas, codeDispatches } = input const resultByCall = indexResults(nodes) + const callStartById = new Map() + for (const result of resultByCall.values()) { + const startedAt = finiteTime(result.callTime) + if (startedAt !== null) callStartById.set(result.callId, startedAt) + } + for (const call of runningCalls) { + const startedAt = finiteTime(call.time) + if (startedAt !== null) callStartById.set(call.callId, startedAt) + } const turns = new Map }>() let index = 0 let prevAbsTime: number | null = null @@ -92,14 +106,21 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T absTime: finiteTime(node.time), cell: { index: ++index, kind: 'user', text: summarizeContent(node.content), + opensTurn: node.kind === 'user', + inputDetail: detailContent(node.content), + sourceBlocks: node.content.map(block => sourceBlock(block)), timeSeconds: 0, + startedAt: finiteTime(node.time), }, }) prevAbsTime = finiteTime(node.time) ?? prevAbsTime continue } if (node.kind === 'assistant') { - const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches) + const laidList = withSubCalls( + expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById), + codeDispatches, + ) for (const laid of laidList) { if (node.step > 0) pushStep(node.turn, node.step, laid) else pushMessage(node.turn, laid) @@ -128,7 +149,14 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T text: node.call !== null ? summarizeCall(node.call.name, node.call.argsRaw) : summarizeResult(node), + ...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}), + outputDetail: detailResult(node), + outputBlocks: node.content.map(block => sourceBlock(block)), + result: summarizeResult(node), + callId: node.callId, + isError: node.isError, timeSeconds: durationSeconds(node.time, node.callTime), + startedAt: finiteTime(node.callTime), }, }) for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) { @@ -145,7 +173,14 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T 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 }) + const laidList = expandAssistant( + fake, + index + 1, + prevAbsTime, + resultByCall, + callStartById, + { streaming: true }, + ) for (const laid of laidList) { if (partial.step > 0) pushStep(partial.turn, partial.step, laid) else pushMessage(partial.turn, laid) @@ -165,7 +200,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T index: ++index, kind: 'tool', text: summarizeCall(call.name, call.argsRaw), + inputDetail: call.argsRaw, + callId: call.callId, timeSeconds: null, + startedAt: finiteTime(call.time), }, }) for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) { @@ -191,11 +229,28 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T turns.set(1, first) } + for (const entry of turns.values()) { + for (const laid of entry.message) attachToolSchema(laid, callSchemas) + for (const laid of entry.steps.values()) { + for (const cell of laid) attachToolSchema(cell, callSchemas) + } + } + return [...turns.entries()] .sort(([a], [b]) => a - b) .map(([turn, entry]) => toTurnModel(turn, entry)) } +function attachToolSchema( + laid: LaidCell, + callSchemas: ConversationSnapshot['callSchemas'], +): void { + if (laid.callId === undefined || callSchemas === undefined) return + const schema = callSchemas.get(laid.callId) + if (schema === undefined) return + laid.cell.schemaDetail = JSON.stringify(schema, null, 2) +} + function toTurnModel( turn: number, entry: { message: LaidCell[]; steps: Map }, @@ -267,8 +322,8 @@ function durationSeconds(later: number, earlier: number | null): number | null { } /** Epoch-ms usable as an absolute time, else null. */ -function finiteTime(time: number): number | null { - return Number.isFinite(time) ? time : null +function finiteTime(time: number | null | undefined): number | null { + return typeof time === 'number' && Number.isFinite(time) ? time : null } function expandAssistant( @@ -276,6 +331,7 @@ function expandAssistant( startIndex: number, prevAbsTime: number | null, results: Map, + callStarts: ReadonlyMap, opts?: { streaming?: boolean }, ): LaidCell[] { const out: LaidCell[] = [] @@ -284,58 +340,155 @@ function expandAssistant( const streaming = opts?.streaming === true const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime) const nodeAbs = streaming ? null : finiteTime(node.time) - let usageAttached = false + const messageText = node.blocks + .filter(block => block.kind === 'text' && (!streaming || block.text !== '')) + .map(block => block.kind === 'text' ? block.text : '') + .join('\n\n') + const thinkingText = node.blocks + .filter(block => block.kind === 'reasoning' && (!streaming || block.text !== '')) + .map(block => block.kind === 'reasoning' ? block.text : '') + .join('\n\n') + const message: TrajectoryCellProps = { + index: ++index, + kind: 'message', + text: messageText !== '' + ? summarizeText(messageText) + : thinkingText !== '' + ? summarizeText(thinkingText) + : summarizeAssistantActivity(node.blocks), + ...(messageText !== '' ? { outputDetail: messageText } : {}), + ...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}), + sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)), + timeSeconds: messageDuration, + startedAt: finiteTime(node.timing?.stepStartTime), + } + attachUsage(message, usage) + message.assistantMetrics = { + timingRecorded: node.timing !== undefined, + stepStartTime: node.timing?.stepStartTime ?? null, + firstTokenTime: node.timing?.firstTokenTime ?? null, + completedTime: streaming ? null : finiteTime(node.time), + usageProvided: usage !== undefined, + outputTokens: Number.isFinite(usage?.outputTokens) ? usage?.outputTokens ?? null : null, + } + out.push({ absTime: nodeAbs, cell: message }) 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) { - attachUsage(cell, usage) - usageAttached = usage !== undefined - } - 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, + // Text and reasoning belong to the one Assistant record emitted above. + if (block.kind !== 'tool-call') continue + const result = results.get(block.callId) + const toolDuration = streaming || result === undefined + ? null + : durationSeconds(result.time, result.callTime) + const callAbs = finiteTime(callStarts.get(block.callId)) + out.push({ + absTime: callAbs, + toolName: block.name, + callId: block.callId, + cell: { + index: ++index, kind: 'tool', + text: summarizeCall(block.name, block.argsRaw), + inputDetail: block.argsRaw, callId: block.callId, - cell: { - index: ++index, kind: 'tool', - text: summarizeCall(block.name, block.argsRaw), - timeSeconds: toolDuration, - }, - }) - } - } - - if (out.length === 0 && !streaming) { - // Reasoning-only / empty success still owns provider usage on the Message row. - const cell: TrajectoryCellProps = { - index: ++index, kind: 'message', text: '', timeSeconds: messageDuration, - } - attachUsage(cell, usage) - out.push({ absTime: nodeAbs, cell }) + ...(result !== undefined + ? { + outputDetail: detailResult(result), + outputBlocks: result.content.map(block => sourceBlock(block)), + result: summarizeResult(result), + isError: result.isError, + } + : {}), + timeSeconds: toolDuration, + startedAt: callAbs, + }, + }) } return out } +function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string { + const tools = new Map() + for (const block of blocks) { + if (block.kind !== 'tool-call') continue + tools.set(block.name, (tools.get(block.name) ?? 0) + 1) + } + if (tools.size > 0) { + return 'Tool call only' + } + return '' +} + +function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { + switch (block.kind) { + case 'text': return { type: 'text', content: block.text } + case 'reasoning': return { type: 'thinking', content: block.text } + case 'tool-call': return { + type: 'tool-call', + content: block.argsRaw, + callId: block.callId, + toolName: block.name, + } + case 'other': return sourceBlock(block.block) + } +} + +function sourceBlock(value: unknown): TrajectorySourceBlock { + if (typeof value !== 'object' || value === null) { + return { type: 'unknown', content: stringifySourceValue(value) } + } + const block = value as Record + const type = typeof block.type === 'string' ? block.type : 'unknown' + if (typeof block.text === 'string') { + return { type: type === 'reasoning' ? 'thinking' : type, content: block.text } + } + const imageSrc = sourceImage(block) + const imageAlt = typeof block.alt === 'string' ? block.alt : undefined + return { + type, + content: imageSrc === undefined ? stringifySourceValue(value) : '', + ...(imageSrc !== undefined ? { imageSrc } : {}), + ...(imageAlt !== undefined ? { imageAlt } : {}), + } +} + +function sourceImage(block: Record): string | undefined { + if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined + for (const candidate of [block.url, block.image_url]) { + if (typeof candidate === 'string') return safeImageSource(candidate) + } + if (typeof block.data === 'string') { + const mediaType = [block.mimeType, block.mediaType, block.media_type] + .find((candidate): candidate is string => typeof candidate === 'string') + ?? 'image/png' + return safeImageSource( + block.data.startsWith('data:') + ? block.data + : `data:${mediaType};base64,${block.data}`, + ) + } + if (typeof block.source !== 'object' || block.source === null) return undefined + const source = block.source as Record + if (typeof source.url === 'string') return safeImageSource(source.url) + if (typeof source.data !== 'string') return undefined + const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png' + return safeImageSource(`data:${mediaType};base64,${source.data}`) +} + +function safeImageSource(value: string): string | undefined { + if (value.startsWith('data:image/') || value.startsWith('blob:')) return value + try { + const protocol = new URL(value).protocol + return protocol === 'http:' || protocol === 'https:' ? value : undefined + } catch { + return undefined + } +} + +function stringifySourceValue(value: unknown): string { + const json = JSON.stringify(value, null, 2) + return json ?? String(value) +} + /** * Turn that encloses a user/message: next assistant/steering turn, else the * in-flight partial, else the turn after the last finalized assistant (or 1). @@ -433,12 +586,27 @@ function expandSubCalls( cell: { index: ++index, kind: 'subtool', + callId: sub.callId, text: settled ? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub)) : summarizeCall(sub.name, sub.argsRaw), + ...(settled + ? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {}) + : { inputDetail: sub.argsRaw }), + ...(settled + ? { + outputDetail: detailResult(sub), + outputBlocks: sub.content.map(block => sourceBlock(block)), + result: summarizeResult(sub), + isError: sub.isError, + } + : {}), // PR3's start/settle pair carries per-sub-call wall time; a running // (unsettled) or pre-pair log entry shows the em dash. timeSeconds: settled ? durationSeconds(sub.time, sub.callTime) : null, + startedAt: settled + ? finiteTime(sub.callTime) + : finiteTime(sub.time), }, }) } @@ -448,8 +616,7 @@ function expandSubCalls( 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}` + return `${name} · ${args}` } function summarizeResult(node: ToolResultNode): string { @@ -464,6 +631,27 @@ function summarizeResult(node: ToolResultNode): string { return node.call?.name ?? node.callId } +function detailResult(node: ToolResultNode): string { + if (node.isError) { + return node.error === undefined + ? 'error' + : `${node.error.name}: ${node.error.code}` + } + const text = node.content + .filter(block => block.type === 'text' && typeof block.text === 'string') + .map(block => block.type === 'text' ? block.text : '') + .join('\n') + if (text !== '') return text + return JSON.stringify(node.content, null, 2) +} + +function detailContent(content: readonly { type: string; text?: string }[]): string { + return content + .filter(block => block.type === 'text' && typeof block.text === 'string') + .map(block => block.text ?? '') + .join('\n') +} + 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) diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts new file mode 100644 index 0000000000..9b2a835de9 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -0,0 +1,81 @@ +/** Shared trajectory record data and formatting contracts. */ + +import type { HTMLAttributes } from 'react' + +/** Closed set of trajectory record kinds. */ +export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool' + +/** Recorded inputs needed to derive assistant TTFT and decode throughput. */ +export interface AssistantMetricDetail { + timingRecorded: boolean + stepStartTime: number | null + firstTokenTime: number | null + completedTime: number | null + usageProvided: boolean + outputTokens: number | null +} + +/** One source content block preserved in model order for the details panel. */ +export interface TrajectorySourceBlock { + type: string + content: string + imageSrc?: string + imageAlt?: string + callId?: string + toolName?: string +} + +/** Data and optional presentation attributes for one trajectory record. */ +export interface TrajectoryCellProps extends HTMLAttributes { + /** 1-based record index shown as `#N`. */ + index: number + kind: TrajectoryCellKind + /** Single-line summary; CSS ellipsis when it overflows. */ + text: string + /** Whether this user record opens a new model turn. */ + opensTurn?: boolean + /** Full request/message content for the details panel. */ + inputDetail?: string + /** Full assistant/tool result content for the details panel. */ + outputDetail?: string + /** Full assistant reasoning content for the details panel. */ + thinkingDetail?: string + /** Original message blocks in source order for the details panel. */ + sourceBlocks?: readonly TrajectorySourceBlock[] + /** Original tool result blocks in source order for the details panel. */ + outputBlocks?: readonly TrajectorySourceBlock[] + /** Call-time model-visible tool schema for the details panel. */ + schemaDetail?: string + /** Assistant-only timing and token facts for the details panel. */ + assistantMetrics?: AssistantMetricDetail + /** Tool-only result summary paired with the call in the same record. */ + result?: string + /** Tool call id used to link message source blocks to tool records. */ + callId?: string + /** Tool-only result failure state. */ + isError?: boolean + /** Own duration in seconds, or `null` when no duration is known. */ + timeSeconds: number | null + /** Unix epoch milliseconds when this operation actually started, when known. */ + startedAt?: number | null + /** Message-only prompt token count. */ + input?: number + /** Message-only completion token count. */ + output?: number + /** Message-only reasoning token count. */ + think?: number + /** Whether the legacy standalone cell renders its selection treatment. */ + selected?: boolean +} + +/** + * Format own-duration for the trailing time column. + * @param seconds - Duration seconds, or `null` when absent. + * @returns `—` when unknown, otherwise a signed seconds label. + */ +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` +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 16a853c441..4f378e7fc3 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -1,17 +1,24 @@ -/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge; - * cell content width is capped on the turn body (max 880). */ +/* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */ .root { - overflow-y: auto; + --dsh-trajectory-toolbar-height: 48px; + + display: flex; + flex-direction: column; + overflow: hidden; height: 100%; min-height: 0; width: 100%; box-sizing: border-box; color: var(--dsw-alias-label-primary); - background: var(--dsw-specific-sidebar-fill); + background: var(--dsw-alias-bg-layer-1); } .empty { - padding: 16px; + display: grid; + flex: 1; + margin: 0; + padding: 24px; + place-items: center; color: var(--dsw-alias-label-tertiary); font: var(--dsw-font-xs-13); } diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index b74782a4c4..a78ca1ba42 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -139,7 +139,7 @@ describe('deriveTrajectoryLayout', () => { }, ] as unknown as ConversationSnapshot['nodes'] const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) - expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2') + expect(turns[0]?.groups[0]?.description).toBe('3s bash×2') }) it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => { @@ -161,7 +161,7 @@ describe('deriveTrajectoryLayout', () => { expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2']) }) - it('keeps usage on the fallback Message row when assistant has no text block', () => { + it('keeps usage and a meaningful summary when assistant has no text block', () => { const nodes = [ { kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0, @@ -172,7 +172,7 @@ describe('deriveTrajectoryLayout', () => { const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message') expect(message).toMatchObject({ - text: '', input: 11, output: 22, think: 3, + text: '仅推理输出', input: 11, output: 22, think: 3, }) }) @@ -235,11 +235,12 @@ describe('run_code sub-dispatch cells', () => { ]]]) as unknown as ConversationSnapshot['codeDispatches'] const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] }) const cells = turns[0]!.groups.flatMap((g) => g.cells) - expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool']) + expect(cells.map((c) => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool']) + expect(cells[0]?.text).toBe('请求调用 run_code') // Sequential indexes across the interleave; durations from the pair times. - expect(cells.map((c) => c.index)).toEqual([1, 2, 3]) - expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 }) - expect(cells[2]).toMatchObject({ timeSeconds: 0.5 }) + expect(cells.map((c) => c.index)).toEqual([1, 2, 3, 4]) + expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 }) + expect(cells[3]).toMatchObject({ timeSeconds: 0.5 }) }) it('a running (unsettled) sub-call renders a subtool cell with blank time', () => { diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx new file mode 100644 index 0000000000..3b096a1955 --- /dev/null +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +/** Trajectory ledger selection, details, status, and fold behavior. */ + +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' +import type { TrajectoryTurnModel } from '../src/client/layout.ts' + +afterEach(cleanup) + +const TURNS: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + description: '1.5s bash×2', + cells: [ + { + index: 1, + kind: 'message', + text: 'Checking files', + outputDetail: 'Checking files', + input: 10, + output: 20, + think: 5, + timeSeconds: 1.5, + assistantMetrics: { + timingRecorded: true, + stepStartTime: 1_000, + firstTokenTime: 1_500, + completedTime: 2_500, + usageProvided: true, + outputTokens: 20, + }, + }, + { + index: 2, + kind: 'tool', + text: 'bash · {"command":"pwd"}', + inputDetail: '{"command":"pwd"}', + timeSeconds: null, + }, + { + index: 3, + kind: 'tool', + text: 'bash · {"command":"false"}', + inputDetail: '{"command":"false"}', + outputDetail: 'ToolError: non_zero_exit', + result: 'non_zero_exit', + isError: true, + timeSeconds: 0.2, + }, + ], + }], +}] + +describe('TrajectoryTable', () => { + it('shows assistant timing facts after keyboard selection', () => { + render() + fireEvent.keyDown(screen.getByRole('row', { name: /记录 1,ASSISTANT/ }), { key: 'Enter' }) + fireEvent.click(screen.getByRole('tab', { name: '计时' })) + + expect(screen.getByText('500 ms')).toBeTruthy() + expect(screen.getByText('1.00 s')).toBeTruthy() + expect(screen.getByText('20.0 tok/s')).toBeTruthy() + }) + + it('keeps running and failure semantics distinct from record roles', () => { + const view = render() + expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy() + expect(view.container.querySelector('tr[data-kind="tool"][data-error="true"]')).toBeTruthy() + + fireEvent.click(screen.getByRole('row', { name: /记录 2,TOOL/ })) + expect(screen.getByText('进行中')).toBeTruthy() + fireEvent.click(screen.getByRole('row', { name: /记录 3,TOOL/ })) + expect(screen.getByText('失败')).toBeTruthy() + fireEvent.click(screen.getByRole('tab', { name: '输出' })) + expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy() + }) + + it('retains the ledger header and record count when collapsed', () => { + render() + expect(screen.getByRole('columnheader', { name: '事件' })).toBeTruthy() + expect(screen.queryByRole('columnheader', { name: 'Tokens' })).toBeNull() + expect(screen.queryByRole('columnheader', { name: '耗时' })).toBeNull() + expect(screen.getByText('3 条记录已收起')).toBeTruthy() + expect(screen.queryByRole('row', { name: /记录 1,ASSISTANT/ })).toBeNull() + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index cbc8760dae..b755fe8b5f 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -4,7 +4,7 @@ * 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, trajectory renders the - * turn-list chrome (no span stats bar), waterfall keeps in-body stats, and + * event-ledger 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' @@ -169,22 +169,48 @@ describe('plugin registration', () => { }) describe('tab switching in ConversationRoot', () => { - it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => { + it('renders all three tabs, defaults to chat, and switches to the trajectory ledger', async () => { const b = await bench() - mount(b.slots) + const view = 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' })) 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(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) + expect(screen.getAllByLabelText('Step 1')).toHaveLength(2) + expect(screen.getByRole('columnheader', { name: '事件' })).toBeTruthy() + expect(screen.getByRole('columnheader', { name: '内容' })).toBeTruthy() + expect(screen.queryByRole('columnheader', { name: 'Tokens' })).toBeNull() + expect(screen.queryByRole('columnheader', { name: '耗时' })).toBeNull() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' }).textContent).toContain('4 条记录') + fireEvent.click(screen.getByRole('button', { name: '收起记录' })) + expect(screen.getByText('4 条记录已收起')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '展开记录' })) + expect(screen.getByRole('row', { name: /记录 1,USER/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() }) + it('opens a local record inspector and switches payload tabs without opening chat details', async () => { + const b = await bench() + mount(b.slots) + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + + fireEvent.keyDown(screen.getByRole('row', { name: /记录 3,TOOL/ }), { key: 'Enter' }) + expect(screen.getByRole('complementary', { name: '记录详情' })).toBeTruthy() + expect(screen.getByText('记录 #3')).toBeTruthy() + expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy() + expect(screen.getByText('完成')).toBeTruthy() + + fireEvent.click(screen.getByRole('tab', { name: '输入' })) + expect(screen.getByText('这条记录没有输入载荷')).toBeTruthy() + fireEvent.click(screen.getByRole('tab', { name: '输出' })) + expect(screen.getByText('[]')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '关闭详情' })) + expect(screen.queryByRole('complementary', { name: '记录详情' })).toBeNull() + }) + it('waterfall renders bars and switching back to chat does not collapse it', async () => { const b = await bench() mount(b.slots) diff --git a/patches/react-json-view-lite@2.5.0.patch b/patches/react-json-view-lite@2.5.0.patch new file mode 100644 index 0000000000..b7b4b7425a --- /dev/null +++ b/patches/react-json-view-lite@2.5.0.patch @@ -0,0 +1,319 @@ +diff --git a/dist/DataRenderer.d.ts b/dist/DataRenderer.d.ts +index 6838d1c3aa8cea1e77534801bf02b9c11ddad1de..3dc8c6568cc4f174126dad32c33a64b0dc25f198 100644 +--- a/dist/DataRenderer.d.ts ++++ b/dist/DataRenderer.d.ts +@@ -30,6 +30,8 @@ interface CommonRenderProps { + clickToExpandNode: boolean; + outerRef: React.RefObject; + beforeExpandChange?: (event: NodeExpandingEvent) => boolean; ++ renderExpandableValue?: (value: Object | Array, expanded: boolean) => React.ReactNode; ++ renderStringValue?: (value: string) => React.ReactNode; + } + export interface JsonRenderProps extends CommonRenderProps { + field?: string; +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 23e9bca4586b7ea71e7d016230e33eb444cd1304..c49677664f0f0a10e67be688a0157ef9f0d93d6d 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -16,10 +16,12 @@ export interface Props extends React.AriaAttributes { + shouldExpandNode?: (level: number, value: any, field?: string) => boolean; + clickToExpandNode?: boolean; + beforeExpandChange?: (event: NodeExpandingEvent) => boolean; ++ renderExpandableValue?: (value: Object | Array, expanded: boolean) => React.ReactNode; ++ renderStringValue?: (value: string) => React.ReactNode; + compactTopLevel?: boolean; + } + export declare const defaultStyles: StyleProps; + export declare const darkStyles: StyleProps; + export declare const allExpanded: () => boolean; + export declare const collapseAllNested: (level: number) => boolean; +-export declare const JsonView: ({ data, style, shouldExpandNode, clickToExpandNode, beforeExpandChange, compactTopLevel, ...ariaAttrs }: Props) => React.JSX.Element; ++export declare const JsonView: ({ data, style, shouldExpandNode, clickToExpandNode, beforeExpandChange, renderExpandableValue, renderStringValue, compactTopLevel, ...ariaAttrs }: Props) => React.JSX.Element; +diff --git a/dist/index.js b/dist/index.js +index ab8b98c992bd34e00e7c3a0257d6778d7928dde8..669304fbba450c789bf4a4264f0627a8eec9d178 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -50,7 +50,9 @@ function ExpandableObject(_ref) { + shouldExpandNode, + clickToExpandNode, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref; + const shouldExpandNodeCalledRef = React.useRef(false); + const [expanded, setExpanded] = React.useState(() => shouldExpandNode(level, value, field)); +@@ -146,7 +148,7 @@ function ExpandableObject(_ref) { + onKeyDown: onKeyDown + }, quoteString(field, style.quotesForFieldNames), ":")) : (/*#__PURE__*/React.createElement("span", { + className: style.label +- }, quoteString(field, style.quotesForFieldNames), ":"))), /*#__PURE__*/React.createElement("span", { ++ }, quoteString(field, style.quotesForFieldNames), ":"))), renderExpandableValue ? renderExpandableValue(value, expanded) : /*#__PURE__*/React.createElement("span", { + className: style.punctuation + }, openBracket), expanded ? (/*#__PURE__*/React.createElement("ul", { + id: contentsId, +@@ -162,16 +164,19 @@ function ExpandableObject(_ref) { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + beforeExpandChange: beforeExpandChange, +- outerRef: outerRef ++ outerRef: outerRef, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }))))) : ( ++ renderExpandableValue ? null : + /*#__PURE__*/ + React.createElement("span", { + className: style.collapsedContent, + onClick: onClick, + onKeyDown: onKeyDown +- })), /*#__PURE__*/React.createElement("span", { ++ })), renderExpandableValue ? null : /*#__PURE__*/React.createElement("span", { + className: style.punctuation +- }, closeBracket), !lastElement && /*#__PURE__*/React.createElement("span", { ++ }, closeBracket), !renderExpandableValue && !lastElement && /*#__PURE__*/React.createElement("span", { + className: style.punctuation + }, ",")); + } +@@ -207,7 +212,9 @@ function JsonObject(_ref3) { + clickToExpandNode, + level, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref3; + return ExpandableObject({ + field, +@@ -221,7 +228,9 @@ function JsonObject(_ref3) { + clickToExpandNode, + data: Object.keys(value).map(key => [key, value[key]]), + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + }); + } + function JsonArray(_ref4) { +@@ -234,7 +243,9 @@ function JsonArray(_ref4) { + shouldExpandNode, + clickToExpandNode, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref4; + return ExpandableObject({ + field, +@@ -246,9 +257,11 @@ function JsonArray(_ref4) { + style, + shouldExpandNode, + clickToExpandNode, +- data: value.map(element => [undefined, element]), ++ data: value.map((element, index) => [String(index), element]), + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + }); + } + function JsonPrimitiveValue(_ref5) { +@@ -256,7 +269,8 @@ function JsonPrimitiveValue(_ref5) { + field, + value, + style, +- lastElement ++ lastElement, ++ renderStringValue + } = _ref5; + let stringValue; + let valueStyle = style.otherValue; +@@ -291,7 +305,7 @@ function JsonPrimitiveValue(_ref5) { + "aria-selected": undefined + }, (field || field === '') && (/*#__PURE__*/React.createElement("span", { + className: style.label +- }, quoteString(field, style.quotesForFieldNames), ":")), /*#__PURE__*/React.createElement("span", { ++ }, quoteString(field, style.quotesForFieldNames), ":")), isString(value) && renderStringValue ? renderStringValue(value) : /*#__PURE__*/React.createElement("span", { + className: valueStyle + }, stringValue), !lastElement && /*#__PURE__*/React.createElement("span", { + className: style.punctuation +@@ -365,6 +379,8 @@ const JsonView = _ref => { + shouldExpandNode = allExpanded, + clickToExpandNode = false, + beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue, + compactTopLevel, + ...ariaAttrs + } = _ref; +@@ -390,7 +406,9 @@ const JsonView = _ref => { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + beforeExpandChange: beforeExpandChange, +- outerRef: outerRef ++ outerRef: outerRef, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }); + }) : (/*#__PURE__*/React.createElement(DataRender, { + value: data, +@@ -403,7 +421,9 @@ const JsonView = _ref => { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + outerRef: outerRef, +- beforeExpandChange: beforeExpandChange ++ beforeExpandChange: beforeExpandChange, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }))); + }; + +diff --git a/dist/index.modern.js b/dist/index.modern.js +index e25aea1bc8936b69275b9fbe7011d0466480f99c..f4c509e4459f70080b90695c00f8a17e8f4aac4e 100644 +--- a/dist/index.modern.js ++++ b/dist/index.modern.js +@@ -50,7 +50,9 @@ function ExpandableObject(_ref) { + shouldExpandNode, + clickToExpandNode, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref; + const shouldExpandNodeCalledRef = useRef(false); + const [expanded, setExpanded] = useState(() => shouldExpandNode(level, value, field)); +@@ -146,7 +148,7 @@ function ExpandableObject(_ref) { + onKeyDown: onKeyDown + }, quoteString(field, style.quotesForFieldNames), ":")) : (/*#__PURE__*/createElement("span", { + className: style.label +- }, quoteString(field, style.quotesForFieldNames), ":"))), /*#__PURE__*/createElement("span", { ++ }, quoteString(field, style.quotesForFieldNames), ":"))), renderExpandableValue ? renderExpandableValue(value, expanded) : /*#__PURE__*/createElement("span", { + className: style.punctuation + }, openBracket), expanded ? (/*#__PURE__*/createElement("ul", { + id: contentsId, +@@ -162,16 +164,19 @@ function ExpandableObject(_ref) { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + beforeExpandChange: beforeExpandChange, +- outerRef: outerRef ++ outerRef: outerRef, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }))))) : ( ++ renderExpandableValue ? null : + /*#__PURE__*/ + createElement("span", { + className: style.collapsedContent, + onClick: onClick, + onKeyDown: onKeyDown +- })), /*#__PURE__*/createElement("span", { ++ })), renderExpandableValue ? null : /*#__PURE__*/createElement("span", { + className: style.punctuation +- }, closeBracket), !lastElement && /*#__PURE__*/createElement("span", { ++ }, closeBracket), !renderExpandableValue && !lastElement && /*#__PURE__*/createElement("span", { + className: style.punctuation + }, ",")); + } +@@ -207,7 +212,9 @@ function JsonObject(_ref3) { + clickToExpandNode, + level, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref3; + return ExpandableObject({ + field, +@@ -221,7 +228,9 @@ function JsonObject(_ref3) { + clickToExpandNode, + data: Object.keys(value).map(key => [key, value[key]]), + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + }); + } + function JsonArray(_ref4) { +@@ -234,7 +243,9 @@ function JsonArray(_ref4) { + shouldExpandNode, + clickToExpandNode, + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + } = _ref4; + return ExpandableObject({ + field, +@@ -246,9 +257,11 @@ function JsonArray(_ref4) { + style, + shouldExpandNode, + clickToExpandNode, +- data: value.map(element => [undefined, element]), ++ data: value.map((element, index) => [String(index), element]), + outerRef, +- beforeExpandChange ++ beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue + }); + } + function JsonPrimitiveValue(_ref5) { +@@ -256,7 +269,8 @@ function JsonPrimitiveValue(_ref5) { + field, + value, + style, +- lastElement ++ lastElement, ++ renderStringValue + } = _ref5; + let stringValue; + let valueStyle = style.otherValue; +@@ -291,7 +305,7 @@ function JsonPrimitiveValue(_ref5) { + "aria-selected": undefined + }, (field || field === '') && (/*#__PURE__*/createElement("span", { + className: style.label +- }, quoteString(field, style.quotesForFieldNames), ":")), /*#__PURE__*/createElement("span", { ++ }, quoteString(field, style.quotesForFieldNames), ":")), isString(value) && renderStringValue ? renderStringValue(value) : /*#__PURE__*/createElement("span", { + className: valueStyle + }, stringValue), !lastElement && /*#__PURE__*/createElement("span", { + className: style.punctuation +@@ -365,6 +379,8 @@ const JsonView = _ref => { + shouldExpandNode = allExpanded, + clickToExpandNode = false, + beforeExpandChange, ++ renderExpandableValue, ++ renderStringValue, + compactTopLevel, + ...ariaAttrs + } = _ref; +@@ -390,7 +406,9 @@ const JsonView = _ref => { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + beforeExpandChange: beforeExpandChange, +- outerRef: outerRef ++ outerRef: outerRef, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }); + }) : (/*#__PURE__*/createElement(DataRender, { + value: data, +@@ -403,7 +421,9 @@ const JsonView = _ref => { + shouldExpandNode: shouldExpandNode, + clickToExpandNode: clickToExpandNode, + outerRef: outerRef, +- beforeExpandChange: beforeExpandChange ++ beforeExpandChange: beforeExpandChange, ++ renderExpandableValue: renderExpandableValue, ++ renderStringValue: renderStringValue + }))); + }; + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e7b31fe0..3491486b69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + react-json-view-lite@2.5.0: 8caeb240cf32d6060a32781982025430c919fe9eba857054195179dd08acff5e + importers: .: @@ -994,12 +997,24 @@ importers: clsx: specifier: ^2.0.0 version: 2.1.1 + mdast-util-from-markdown: + specifier: ^2.0.3 + version: 2.0.3 + mdast-util-gfm: + specifier: ^3.1.0 + version: 3.1.0 + micromark-extension-gfm: + specifier: ^3.0.0 + version: 3.0.0 react: specifier: ^18.2.0 version: 18.3.1 react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + react-json-view-lite: + specifier: ^2.5.0 + version: 2.5.0(patch_hash=8caeb240cf32d6060a32781982025430c919fe9eba857054195179dd08acff5e)(react@18.3.1) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.31)(react@18.3.1) @@ -1279,6 +1294,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -9283,6 +9301,12 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-json-view-lite@2.5.0: + resolution: {integrity: sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==} + engines: {node: '>=18'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -14434,6 +14458,10 @@ snapshots: react-is@17.0.2: {} + react-json-view-lite@2.5.0(patch_hash=8caeb240cf32d6060a32781982025430c919fe9eba857054195179dd08acff5e)(react@18.3.1): + dependencies: + react: 18.3.1 + react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1): dependencies: '@types/hast': 3.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8da07afcf0..ed1bca64f2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,3 +54,6 @@ minimumReleaseAgeExclude: # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - '@earendil-works/pi-ai@0.81.1' + +patchedDependencies: + react-json-view-lite@2.5.0: patches/react-json-view-lite@2.5.0.patch