feat(ui): add trajectory inspection ledger
This commit is contained in:
@@ -1,6 +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
|
||||
README.md: 9e7dee8d5572baad13d7598d1fbde7b042dd5ab4
|
||||
README.zh.md: da14d5265c16acb4e75d21bc445ef9702c83e697
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||
README.md: 884db29b746124ecc1d7a8d2312101b2930c113f
|
||||
README.zh.md: a4d7d4513317692798bb6ea8b453293fa05b6cbe
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. The package also provides the Waterfall view and remains a pure-consumer plugin (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **In-flight Time stays blank** — `partial` / `runningCalls` rows render with `—` until a live clock policy lands; selected styling is local-only (not wired to chat details); anchor deep-linking remains deferred.
|
||||
- **In-flight Time stays blank** — `partial` / `runningCalls` rows show their running state without a fabricated duration until a live clock policy lands; record selection is intentionally local to Trajectory; anchor deep-linking remains deferred.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
轨迹轮次列表 chrome(吸顶 Turn/Message·Step 分组/步骤单元格)及 Waterfall 占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。该包(package)还提供 waterfall(瀑布式事件)视图,并保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **进行中的 Time 保持空白**:`partial`/`runningCalls` 行在实时钟策略落地前渲染为 `—`;选中样式只在本地生效(未连接到聊天详情);锚点深链接仍暂缓实现。
|
||||
- **进行中的 Time 保持空白**:`partial`/`runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时;记录选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-trajectory",
|
||||
"description": "Trajectory/Waterfall placeholder views: pure-consumer plugin registering into the conversation ViewMap (no service)",
|
||||
"description": "Trajectory event-ledger and Waterfall views: pure-consumer plugin registering into the conversation ViewMap (no service)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -35,11 +35,13 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// TrajectoryCell: one step row in the trajectory list — index, kind tag,
|
||||
// ellipsis text, optional Message token metrics, and own-duration time.
|
||||
// Legacy standalone trajectory cell retained for direct consumers and specs.
|
||||
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import {
|
||||
formatElapsedSeconds,
|
||||
type TrajectoryCellKind,
|
||||
type TrajectoryCellProps,
|
||||
} from './trajectory-record.ts'
|
||||
import css from './TrajectoryCell.module.css'
|
||||
|
||||
/** Closed set of trajectory step kinds (call+result fold into Tool; no Think;
|
||||
* subtool = one run_code sub-dispatch nested under its Tool cell). */
|
||||
export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool'
|
||||
export { formatElapsedSeconds }
|
||||
export type {
|
||||
AssistantMetricDetail,
|
||||
TrajectoryCellKind,
|
||||
TrajectoryCellProps,
|
||||
} from './trajectory-record.ts'
|
||||
|
||||
/** Display label per kind (matches the design tags). */
|
||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
@@ -23,40 +29,6 @@ const TAG_CLASS: Record<TrajectoryCellKind, string> = {
|
||||
subtool: css.tagSubtool!,
|
||||
}
|
||||
|
||||
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 1-based step index shown as `#N`. */
|
||||
index: number
|
||||
kind: TrajectoryCellKind
|
||||
/** Single-line summary; CSS ellipsis when it overflows. */
|
||||
text: string
|
||||
/**
|
||||
* Own duration in seconds. `null` means no duration to show (em dash) —
|
||||
* used for in-flight tools and tools missing callTime.
|
||||
*/
|
||||
timeSeconds: number | null
|
||||
/** Message-only: prompt token count. */
|
||||
input?: number
|
||||
/** Message-only: completion token count. */
|
||||
output?: number
|
||||
/** Message-only: reasoning token count (usage column, not a Think cell). */
|
||||
think?: number
|
||||
/** Selected: 2px inset brand-primary-new-color ring (not wired to chat selection yet). */
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Format own-duration for the trailing time column: `—` when unknown, `+Ns`
|
||||
* or `+N.1s` otherwise.
|
||||
* @param seconds - duration seconds, or null when absent.
|
||||
* @returns display string.
|
||||
*/
|
||||
export function formatElapsedSeconds(seconds: number | null): string {
|
||||
if (seconds === null || !Number.isFinite(seconds)) return '—'
|
||||
const rounded = Math.round(seconds * 10) / 10
|
||||
if (Number.isInteger(rounded)) return `+${rounded}s`
|
||||
return `+${rounded.toFixed(1)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one trajectory step cell.
|
||||
* @param props - index, kind, text, time, and optional Message metrics.
|
||||
@@ -66,7 +38,18 @@ export function TrajectoryCell({
|
||||
index,
|
||||
kind,
|
||||
text,
|
||||
inputDetail: _inputDetail,
|
||||
outputDetail: _outputDetail,
|
||||
thinkingDetail: _thinkingDetail,
|
||||
sourceBlocks: _sourceBlocks,
|
||||
outputBlocks: _outputBlocks,
|
||||
schemaDetail: _schemaDetail,
|
||||
assistantMetrics: _assistantMetrics,
|
||||
result: _result,
|
||||
callId: _callId,
|
||||
isError: _isError,
|
||||
timeSeconds,
|
||||
startedAt: _startedAt,
|
||||
input,
|
||||
output,
|
||||
think,
|
||||
|
||||
1067
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css
Normal file
1067
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css
Normal file
File diff suppressed because it is too large
Load Diff
1301
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
Normal file
1301
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
|
||||
<div className={css.inner}>
|
||||
<div className={css.summary}>
|
||||
<span className={css.title}>Trajectory</span>
|
||||
</div>
|
||||
<div className={css.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
disabled={collapsibleAssistants === 0}
|
||||
onClick={onToggleAllAssistants}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
{allAssistantsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
{allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
disabled={collapsibleTurns === 0}
|
||||
onClick={onToggleAllTurns}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
{allTurnsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
{allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<ReadonlySet<number>>(() => new Set())
|
||||
const [collapsedAssistants, setCollapsedAssistants] = useState<ReadonlySet<number>>(() => 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 <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
|
||||
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 (
|
||||
<div className={css.root}>
|
||||
{turns.map((turn) => (
|
||||
<TrajectoryTurn key={turn.turn} turn={turn.turn}>
|
||||
{turn.groups.flatMap((group) => [
|
||||
<TrajectoryGroupHeader
|
||||
key={`${group.title}-h`}
|
||||
title={group.title}
|
||||
{...(group.description !== undefined ? { description: group.description } : {})}
|
||||
/>,
|
||||
...group.cells.map((cell) => (
|
||||
<TrajectoryCell key={cell.index} {...cell} />
|
||||
)),
|
||||
])}
|
||||
</TrajectoryTurn>
|
||||
))}
|
||||
<TrajectoryToolbar
|
||||
collapsibleTurns={collapsibleTurnIds.length}
|
||||
allTurnsCollapsed={allTurnsCollapsed}
|
||||
onToggleAllTurns={toggleAllTurns}
|
||||
collapsibleAssistants={collapsibleAssistantIds.length}
|
||||
allAssistantsCollapsed={allAssistantsCollapsed}
|
||||
onToggleAllAssistants={toggleAllAssistants}
|
||||
/>
|
||||
{turns.length === 0 && <p className={css.empty}>No trajectory events</p>}
|
||||
{turns.length > 0 && (
|
||||
<TrajectoryTable
|
||||
turns={turns}
|
||||
collapsedTurns={collapsedTurns}
|
||||
onToggleTurn={toggleTurn}
|
||||
collapsedAssistants={collapsedAssistants}
|
||||
onToggleAssistant={toggleAssistant}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <div className={css.root}><p className={css.empty}>暂无瀑布数据</p></div>
|
||||
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>No timing data</p></div>
|
||||
return (
|
||||
<>
|
||||
<TrajectoryStatsHeader useSession={useSession} />
|
||||
|
||||
@@ -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<string, number>()
|
||||
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<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
|
||||
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<number, LaidCell[]> },
|
||||
@@ -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<string, ToolResultNode>,
|
||||
callStarts: ReadonlyMap<string, number>,
|
||||
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<string, number>()
|
||||
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<string, unknown>
|
||||
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, unknown>): 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<string, unknown>
|
||||
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)
|
||||
|
||||
@@ -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<HTMLDivElement> {
|
||||
/** 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`
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
88
packages/client/ui-trajectory/tests/table.spec.tsx
Normal file
88
packages/client/ui-trajectory/tests/table.spec.tsx
Normal file
@@ -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(<TrajectoryTable turns={TURNS} collapsed={false} />)
|
||||
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(<TrajectoryTable turns={TURNS} collapsed={false} />)
|
||||
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(<TrajectoryTable turns={TURNS} collapsed />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user