feat(client): add trajectory timing overview

This commit is contained in:
_Kerman
2026-07-28 16:28:57 +08:00
parent 40c29495af
commit 1d4a6149cc
39 changed files with 716 additions and 592 deletions

View File

@@ -150,8 +150,11 @@ function deriveCallSchemas(
}
for (const event of events) {
if (event.type === 'request/header') {
const tools: unknown = event.data.header.tools
active = new Map(
(event.data.header.tools ?? []).map(schema => [schema.name, schema]),
Array.isArray(tools)
? (tools as ToolSchema[]).map(schema => [schema.name, schema])
: [],
)
continue
}

View File

@@ -105,4 +105,25 @@ describe('inspectRequests', () => {
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
})
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
tools: '{{tools}}',
},
}),
at(1, 'tool/call', {
turn: 1,
step: 1,
callId: 'call-1',
name: 'read',
arguments: '{}',
}),
]))
expect(snapshot.callSchemas).toEqual(new Map())
})
})

View File

@@ -19,7 +19,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps }
/**
* The conversation view ring: one list entry per view tab (chat here;
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
* Trajectory from ui-trajectory), rendered one-at-a-time by
* ConversationRoot via `only: <active id>`. Declared by this package's
* 'conversation' entry (declaring is claiming). Session scope: views read
* the conversation snapshot through the standard kit.
@@ -133,7 +133,7 @@ export interface ConvViewOwnerProps {}
/**
* Owner share of a per-view toolview slot: the call material the rendering
* view supplies per row. Uniform across views — the trajectory/waterfall
* view supplies per row. Uniform across views — the Trajectory
* toolview slots (same kind/scope/owner, names fixed by the slot-naming
* discipline) land with their own row render sites; today only the chat slot
* is declared (RendersCheck rejects a declaration nobody renders).

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: 884db29b746124ecc1d7a8d2312101b2930c113f
README.zh.md: a4d7d4513317692798bb6ea8b453293fa05b6cbe
README.md: ee6fb97894ec7503ccb9c0cb6a74c122fbcd852c
README.zh.md: 238a499f646a4bf637c037f8906be6ae684bb013

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. The package also provides the Waterfall view and remains a pure-consumer plugin (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A fixed Overview above the ledger projects real record start/duration timing from left to right; dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
## Model Experience
@@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **In-flight Time stays blank** — `partial` / `runningCalls` rows show their running state without a fabricated duration until a live clock policy lands; record selection is intentionally local to Trajectory; anchor deep-linking remains deferred.
- **In-flight Time stays blank** — `partial` / `runningCalls` rows show their running state without a fabricated duration until a live clock policy lands, so the Overview renders a start marker rather than inventing a live span; record and timeline selection are intentionally local to Trajectory; anchor deep-linking remains deferred.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。该包package还提供 waterfall瀑布式事件视图保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。该包package保持为纯消费方插件向会话的 `'conversation.view'` slot 环注册个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
## 模型体验
@@ -14,4 +14,4 @@ Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助
## 已知限制与暂缓事项
- **进行中的 Time 保持空白**`partial``runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时;记录选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。
- **进行中的 Time 保持空白**`partial``runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时,因此 Overview 区域只渲染开始标记,而不会杜撰实时跨度;记录选择与时间线选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-trajectory",
"description": "Trajectory event-ledger and Waterfall views: pure-consumer plugin registering into the conversation ViewMap (no service)",
"description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -1,7 +0,0 @@
.root {
padding: 4px 16px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
border-bottom: 1px solid var(--dsw-alias-border-l2);
}

View File

@@ -1,21 +0,0 @@
// TrajectoryStatsHeader: span totals row rendered at the top of both
// placeholder view bodies (chrome dissolved into the views — the header is
// part of what these views ARE, not registration metadata). Subscribes to
// `nodes` only: chunk batches never swap that reference, so the row is quiet
// during streaming.
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { deriveSpans, deriveSpanStats } from './spans.ts'
import css from './TrajectoryStatsHeader.module.css'
/** Props: the conversation-snapshot selector hook (handed down by the view body). */
export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) {
const nodes = useSession(s => s.nodes)
const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes])
if (stats.turns === 0) return null
return <div className={css.root}>{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}</div>
})

View File

@@ -0,0 +1,181 @@
.root {
flex: none;
padding: 8px 16px 12px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
user-select: none;
}
.header {
display: flex;
align-items: center;
min-height: 24px;
gap: 8px;
}
.title {
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xs-13);
font-weight: 600;
}
.summary {
flex: 1;
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
}
.clear {
flex: none;
padding: 2px 8px;
border: 0;
border-radius: 4px;
color: var(--dsw-alias-state-business-primary);
background: transparent;
font: var(--dsw-font-xs-13);
cursor: pointer;
}
.clear:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.clear:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
.plot {
position: relative;
height: 72px;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
background: var(--dsw-alias-bg-layer-2);
cursor: crosshair;
touch-action: none;
}
.plot:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
.ticks {
position: absolute;
inset: 0 8px auto;
height: 20px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.tick {
position: absolute;
left: var(--trajectory-tick-left);
padding: 2px 4px;
transform: translateX(-50%);
white-space: nowrap;
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
}
.tick:first-child {
transform: none;
}
.tick:last-child {
transform: translateX(-100%);
}
.tick::after {
position: absolute;
top: 20px;
bottom: -52px;
left: 50%;
width: 1px;
background: var(--dsw-alias-border-l1);
content: '';
}
.lanes {
position: absolute;
inset: 24px 8px 6px;
}
.span {
position: absolute;
top: calc(var(--trajectory-span-lane) * 14px);
left: var(--trajectory-span-left);
width: var(--trajectory-span-width);
height: 8px;
min-width: 2px;
border-radius: 1px;
background: var(--dsw-alias-label-tertiary);
opacity: 0.72;
}
.span[data-timeline-span='message'],
.span[data-timeline-span='compacted'] {
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.span[data-timeline-span='tool'],
.span[data-timeline-span='subtool'] {
background: var(--dsw-alias-state-business-primary);
}
.selection {
position: absolute;
top: 0;
bottom: 0;
left: var(--trajectory-selection-left);
width: var(--trajectory-selection-width);
min-width: 1px;
border-right: 1px solid var(--dsw-alias-state-business-primary);
border-left: 1px solid var(--dsw-alias-state-business-primary);
background: color-mix(
in srgb,
var(--dsw-alias-state-business-primary) 12%,
transparent
);
box-shadow:
-100vw 0 0 100vw color-mix(in srgb, var(--dsw-alias-bg-layer-1) 58%, transparent),
100vw 0 0 100vw color-mix(in srgb, var(--dsw-alias-bg-layer-1) 58%, transparent);
pointer-events: none;
}
.selection::before,
.selection::after {
position: absolute;
top: 0;
bottom: 0;
width: 3px;
background: var(--dsw-alias-state-business-primary);
content: '';
}
.selection::before {
left: -2px;
}
.selection::after {
right: -2px;
}
.selection[data-dragging='true'] {
background: color-mix(
in srgb,
var(--dsw-alias-state-business-primary) 18%,
transparent
);
}
@media (max-width: 720px) {
.root {
padding-right: 12px;
padding-left: 12px;
}
.plot {
height: 64px;
}
}

View File

@@ -0,0 +1,216 @@
/** Chrome-Network-style overview timeline for focusing the trajectory ledger. */
import {
memo, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent,
} from 'react'
import type { TrajectoryTurnModel } from './layout.ts'
import {
deriveTrajectoryTimeline,
filterTrajectoryTimelineRange,
formatTimelineOffset,
type TrajectoryTimeRange,
} from './timeline.ts'
import css from './TrajectoryTimeline.module.css'
const TICK_COUNT = 5
const MINIMUM_DRAG_PX = 3
interface FractionRange {
start: number
end: number
}
/** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
range: TrajectoryTimeRange | null
onRangeChange: (range: TrajectoryTimeRange | null) => void
}
function orderedRange(left: number, right: number): FractionRange {
return left <= right ? { start: left, end: right } : { start: right, end: left }
}
function clampFraction(value: number): number {
return Math.min(1, Math.max(0, value))
}
function rangeFraction(
range: TrajectoryTimeRange,
start: number,
duration: number,
): FractionRange {
return orderedRange(
clampFraction((range.start - start) / duration),
clampFraction((range.end - start) / duration),
)
}
/** Overview renderer with drag-to-filter and Escape/clear reset. */
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
turns,
range,
onRangeChange,
}: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns), [turns])
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
const [draft, setDraft] = useState<FractionRange | null>(null)
const domainDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
const committed = model === null || range === null
? null
: rangeFraction(range, model.start, domainDuration)
const visibleRange = draft ?? committed
const focusedCount = useMemo(
() => range === null
? model?.spans.length ?? 0
: deriveTrajectoryTimeline(filterTrajectoryTimelineRange(turns, range))?.spans.length ?? 0,
[model?.spans.length, range, turns],
)
if (model === null) {
return (
<section className={css.root} aria-label="Trajectory timeline">
<div className={css.header}>
<span className={css.title}>Overview</span>
<span className={css.summary}>No timing data</span>
</div>
</section>
)
}
const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
const rect = event.currentTarget.getBoundingClientRect()
return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
}
const commit = (fraction: FractionRange) => {
onRangeChange({
start: model.start + fraction.start * domainDuration,
end: model.start + fraction.end * domainDuration,
})
}
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
const rect = event.currentTarget.getBoundingClientRect()
const anchor = fractionAt(event)
dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) }
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId)
}
setDraft({ start: anchor, end: anchor })
}
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
setDraft(orderedRange(drag.anchor, fractionAt(event)))
}
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
const selected = orderedRange(drag.anchor, fractionAt(event))
dragRef.current = null
setDraft(null)
if ((selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX) {
onRangeChange(null)
} else {
commit(selected)
}
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Escape' || range === null) return
event.preventDefault()
onRangeChange(null)
}
const onPointerCancel = () => {
dragRef.current = null
setDraft(null)
}
const ticks = Array.from({ length: TICK_COUNT }, (_, index) => {
const fraction = index / (TICK_COUNT - 1)
return {
fraction,
label: formatTimelineOffset(fraction * domainDuration),
}
})
const summary = range === null
? `${model.spans.length} timed events`
: `${focusedCount} of ${model.spans.length} events · ${formatTimelineOffset(range.start - model.start)}${formatTimelineOffset(range.end - model.start)}`
return (
<section className={css.root} aria-label="Trajectory timeline">
<div className={css.header}>
<span className={css.title}>Overview</span>
<span className={css.summary} aria-live="polite">{summary}</span>
{range !== null && (
<button
className={css.clear}
type="button"
onClick={() => {
onRangeChange(null)
}}
>
Clear selection
</button>
)}
</div>
<div
className={css.plot}
aria-label="Timeline overview; drag horizontally to filter events"
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerCancel}
>
<div className={css.ticks} aria-hidden="true">
{ticks.map(tick => (
<span
className={css.tick}
key={tick.fraction}
style={{ '--trajectory-tick-left': `${tick.fraction * 100}%` } as CSSProperties}
>
{tick.label}
</span>
))}
</div>
<div className={css.lanes} aria-hidden="true">
{model.spans.map((span) => {
const left = (span.start - model.start) / domainDuration
const width = (span.end - span.start) / domainDuration
return (
<span
className={css.span}
data-timeline-span={span.kind}
key={span.index}
title={`${span.label} · ${formatTimelineOffset(span.end - span.start)}`}
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
'--trajectory-span-lane': span.lane,
} as CSSProperties}
/>
)
})}
</div>
{visibleRange !== null && (
<div
className={css.selection}
data-dragging={draft === null ? undefined : 'true'}
aria-hidden="true"
style={{
'--trajectory-selection-left': `${visibleRange.start * 100}%`,
'--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
} as CSSProperties}
/>
)}
</div>
</section>
)
})

View File

@@ -14,7 +14,11 @@ import {
type TrajectoryUsage,
} from './TrajectoryTable.tsx'
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
import { deriveTrajectoryLayout } from './layout.ts'
import {
filterTrajectoryTimelineRange, type TrajectoryTimeRange,
} from './timeline.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
@@ -73,6 +77,10 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<number>>(EMPTY_IDS)
const [timelineSelection, setTimelineSelection] = useState<{
branchId: number
range: TrajectoryTimeRange
} | null>(null)
const nodes = useSession(s => s.nodes)
const inspection = useSession(s => s.inspection)
const hasMore = useSession(s => s.hasMore)
@@ -249,8 +257,15 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
],
)
const timelineRange = timelineSelection?.branchId === currentBranch.id
? timelineSelection.range
: null
const focusedTurns = useMemo(
() => filterTrajectoryTimelineRange(turns, timelineRange),
[timelineRange, turns],
)
const collapsibleTurnIds = useMemo(
() => turns
() => focusedTurns
.filter(turn =>
turn.groups.reduce(
(count, group) =>
@@ -259,13 +274,13 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
0,
) > 1)
.map(turn => turn.turn),
[turns],
[focusedTurns],
)
const allTurnsCollapsed = collapsibleTurnIds.length > 0
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
const collapsibleAssistantIds = useMemo(() => {
const ids: number[] = []
for (const turn of turns) {
for (const turn of focusedTurns) {
const cells = turn.groups.flatMap(group => group.cells)
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]
@@ -275,7 +290,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
}
}
return ids
}, [turns])
}, [focusedTurns])
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
@@ -331,11 +346,18 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
allAssistantsCollapsed={allAssistantsCollapsed}
onToggleAllAssistants={toggleAllAssistants}
/>
<TrajectoryTimeline
turns={turns}
range={timelineRange}
onRangeChange={(range) => {
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
}}
/>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.id}
key={`${currentBranch.id}:${timelineRange?.start ?? 'all'}:${timelineRange?.end ?? 'all'}`}
requestNumbers={requestNumbers}
turns={turns}
turns={focusedTurns}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}

View File

@@ -1,75 +0,0 @@
// WaterfallView: span stats header over per-turn node-count lanes (P-I
// stand-in for duration lanes; deviation ledger #3). run_code turns
// additionally draw TRUTHFUL sub-call lanes: the dispatch start/settle pair
// carries per-sub-call wall time, so each sub-span's width is its real
// duration against the parent turn's dispatch window.
import { useMemo } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans, deriveSubSpans } from './spans.ts'
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
import css from './views.module.css'
/** Bar width scale: px per node, clamped so tiny windows still show a bar. */
const PX_PER_NODE = 14
const MIN_BAR_PX = 8
/** Sub-span lane width budget (the parent window scales into this). */
const SUB_LANE_PX = 220
/** Optional density override (test/standalone knob; the register site passes nothing). */
export interface WaterfallExtraProps {
/** Bar-lane density in px per node; defaults to 14. */
pxPerNode?: number
}
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
const scale = pxPerNode ?? PX_PER_NODE
const nodes = useSession(s => s.nodes)
const codeDispatches = useSession(s => s.codeDispatches)
const spans = useMemo(() => deriveSpans(nodes), [nodes])
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>No timing data</p></div>
return (
<>
<TrajectoryStatsHeader useSession={useSession} />
<div className={css.root}>
{spans.map((span, i) => (
<div key={span.turn}>
<div className={css.row} style={{ paddingLeft: i * 12 }}>
<span className={css.turnTag}>turn {span.turn}</span>
<span
className={css.bar}
style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
title={`${span.nodes} nodes`}
/>
{span.calls > 0 && (
<span
className={`${css.bar} ${css.barCalls}`}
style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
title={`${span.calls} tool calls`}
/>
)}
</div>
{(subSpans.get(span.turn) ?? []).map(lane => (
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
<span className={css.subTag}>{lane.name}</span>
<span
className={`${css.bar} ${css.barSub}`}
data-timing={lane.timing}
style={{
marginLeft: Math.round(lane.offsetFraction * SUB_LANE_PX),
width: Math.max(Math.round(lane.widthFraction * SUB_LANE_PX), 4),
}}
title={lane.timing === 'measured'
/* durationMs is non-null exactly when timing is measured. */
? `${lane.name} · ${((lane.durationMs ?? 0) / 1000).toFixed(2)} s`
: lane.timing === 'running' ? `${lane.name} · running` : `${lane.name} · duration unknown`}
/>
</div>
))}
</div>
))}
</div>
</>
)
}

View File

@@ -1,6 +1,6 @@
/**
* Browser trajectory plugin contributing two entries to the conversation
* view slot without defining a service.
* Browser trajectory plugin contributing one entry to the conversation view
* slot without defining a service.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -8,7 +8,6 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// owning package) must be in the program for the register calls to type.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
import { WaterfallView } from './WaterfallView.tsx'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
@@ -20,9 +19,8 @@ import { WaterfallView } from './WaterfallView.tsx'
export const inject = ['slots', 'conversation', 'sessions']
/**
* Client plugin body: register the trajectory and waterfall view tabs. The
* registrations ride the slot service's effect wrapper (plugin unload
* removes both tabs).
* Client plugin body: register the trajectory view tab. The registration
* rides the slot service's effect wrapper, so plugin unload removes the tab.
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
@@ -39,6 +37,4 @@ export function apply(ctx: Context): void {
return { loadAllHistory: () => session.loadAllHistory() }
},
}, TrajectoryView)
ctx.slots.register(
{ name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView)
}

View File

@@ -1,147 +0,0 @@
/**
* Rough per-turn span derivation shared by the two placeholder views and the
* header stats bar. P-I ships no timing data, so a span's weight is its node
* count, not wall time (deviation ledger #3 — real spans land in P-III).
*/
import type { ConversationNode, ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
/** One run_code sub-dispatch lane in the waterfall: real timing off the start/settle pair. */
export interface SubSpanLane {
callId: string
name: string
/** Wall duration in ms; null unless both endpoints were observed (`timing: 'measured'`). */
durationMs: number | null
/**
* Timing provenance: `measured` = start/settle pair observed; `running` =
* start seen, settle pending; `unknown` = settle-only replay window (the
* start fell outside), so no duration claim is possible.
*/
timing: 'measured' | 'running' | 'unknown'
/** Start offset as a fraction of the parent turn's dispatch window [0, 1). */
offsetFraction: number
/** Width as a fraction of the window (running lanes extend to the window end). */
widthFraction: number
}
/** One turn's worth of activity, folded from the snapshot node window. */
export interface TurnSpan {
turn: number
/** Assistant step messages inside the turn. */
steps: number
/** Tool results inside the turn (running calls are not folded in P-I). */
calls: number
/** Total nodes attributed to the turn (span weight stand-in). */
nodes: number
}
/** Aggregate totals for the header stats bar. */
export interface SpanStats {
turns: number
steps: number
calls: number
}
/**
* Fold snapshot nodes into per-turn spans. Only assistant nodes carry a turn
* number; user/steering/context/tool nodes attach to the turn last seen in
* sequence order (turn 0 collects the pre-assistant prologue).
* @param nodes - snapshot nodes in surface order.
* @returns spans ordered by first appearance.
*/
export function deriveSpans(nodes: ConversationSnapshot['nodes']): readonly TurnSpan[] {
const spans = new Map<number, TurnSpan>()
let currentTurn = 0
const spanFor = (turn: number): TurnSpan => {
let span = spans.get(turn)
if (span === undefined) {
span = { turn, steps: 0, calls: 0, nodes: 0 }
spans.set(turn, span)
}
return span
}
for (const node of nodes) {
if (hasTurn(node)) currentTurn = node.turn
const span = spanFor(currentTurn)
span.nodes += 1
if (node.kind === 'assistant') span.steps += 1
if (node.kind === 'tool-result') span.calls += 1
}
return [...spans.values()]
}
/**
* Aggregate spans into the header totals.
* @param spans - deriveSpans product.
* @returns turn/step/call totals.
*/
export function deriveSpanStats(spans: readonly TurnSpan[]): SpanStats {
let steps = 0
let calls = 0
for (const span of spans) {
steps += span.steps
calls += span.calls
}
return { turns: spans.length, steps, calls }
}
function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } {
return node.kind === 'assistant' || node.kind === 'steering'
}
/**
* Fold the dispatch index into per-turn sub-span lanes with REAL timing: each
* lane's offset/width scale against its parent turn's dispatch window (first
* start → last settle). Running (unsettled) lanes extend to the window end
* with a null duration.
* @param nodes - snapshot nodes (locates each parent run_code call's turn).
* @param codeDispatches - the snapshot's dispatch index.
* @returns lanes keyed by turn, in start order.
*/
export function deriveSubSpans(
nodes: ConversationSnapshot['nodes'],
codeDispatches: ConversationSnapshot['codeDispatches'],
): ReadonlyMap<number, readonly SubSpanLane[]> {
const out = new Map<number, SubSpanLane[]>()
if (codeDispatches.size === 0) return out
const turnByCall = new Map<string, number>()
let currentTurn = 0
for (const node of nodes) {
if (node.kind === 'assistant' || node.kind === 'steering') currentTurn = node.turn
if (node.kind === 'tool-result') turnByCall.set(node.callId, currentTurn)
}
for (const [parent, subs] of codeDispatches) {
if (subs.length === 0) continue
const turn = turnByCall.get(parent) ?? currentTurn
// A settle-only entry (callTime null: its start fell outside the replay
// window) anchors the window by its settle time — a real observation —
// but must never masquerade as a measured zero-duration span.
const starts: number[] = []
const ends: number[] = []
for (const sub of subs) {
const settled = 'kind' in sub
const start = settled ? sub.callTime ?? sub.time : sub.time
starts.push(start)
ends.push(settled ? sub.time : start)
}
const windowStart = Math.min(...starts)
const windowEnd = Math.max(...ends, windowStart + 1)
const windowSpan = windowEnd - windowStart
const lanes: SubSpanLane[] = subs.map((sub, i) => {
const settled = 'kind' in sub
const timing = settled ? (sub.callTime === null ? 'unknown' as const : 'measured' as const) : 'running' as const
const start = starts[i] ?? windowStart
const end = settled ? sub.time : windowEnd
return {
callId: sub.callId,
name: settled ? sub.call?.name ?? sub.callId : sub.name,
durationMs: timing === 'measured' ? Math.max(0, end - start) : null,
timing,
offsetFraction: (start - windowStart) / windowSpan,
widthFraction: Math.max((end - start) / windowSpan, 0.02),
}
})
const existing = out.get(turn) ?? []
out.set(turn, [...existing, ...lanes])
}
return out
}

View File

@@ -0,0 +1,110 @@
/** Time-domain projection and filtering for the trajectory overview. */
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
import type { TrajectoryTurnModel } from './layout.ts'
/** Inclusive absolute-time selection in Unix epoch milliseconds. */
export interface TrajectoryTimeRange {
start: number
end: number
}
/** One timed ledger record projected into the overview. */
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
index: number
kind: TrajectoryCellKind
label: string
lane: number
}
/** Full-domain model used by the overview. */
export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
spans: readonly TrajectoryTimelineSpan[]
}
function finite(value: number | null | undefined): value is number {
return value !== null && value !== undefined && Number.isFinite(value)
}
function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null {
if (!finite(cell.startedAt)) return null
const durationMs = finite(cell.timeSeconds)
? Math.max(0, cell.timeSeconds * 1_000)
: 0
return { start: cell.startedAt, end: cell.startedAt + durationMs }
}
function laneFor(kind: TrajectoryCellKind): number {
if (kind === 'tool' || kind === 'subtool') return 2
if (kind === 'message' || kind === 'compacted') return 1
return 0
}
/**
* Project every visible timed record into a stable three-lane overview.
* @param turns - Unfiltered trajectory layout.
* @returns Timeline model, or `null` when no record carries a start time.
*/
export function deriveTrajectoryTimeline(
turns: readonly TrajectoryTurnModel[],
): TrajectoryTimelineModel | null {
const spans = turns.flatMap(turn =>
turn.groups.flatMap(group =>
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
if (cell.requestOnly === true) return []
const range = cellRange(cell)
return range === null
? []
: [{
...range,
index: cell.index,
kind: cell.kind,
label: cell.text,
lane: laneFor(cell.kind),
}]
}),
),
)
if (spans.length === 0) return null
return {
start: Math.min(...spans.map(span => span.start)),
end: Math.max(...spans.map(span => span.end)),
spans,
}
}
function overlaps(cell: TrajectoryCellProps, range: TrajectoryTimeRange): boolean {
const timed = cellRange(cell)
return timed !== null && timed.start <= range.end && timed.end >= range.start
}
/**
* Keep records active at any point inside an inclusive selected interval.
* @param turns - Unfiltered trajectory layout.
* @param range - Absolute selected interval, or `null` for the full ledger.
* @returns A layout retaining original turn, group, and record identities.
*/
export function filterTrajectoryTimelineRange(
turns: readonly TrajectoryTurnModel[],
range: TrajectoryTimeRange | null,
): readonly TrajectoryTurnModel[] {
if (range === null) return turns
return turns.flatMap((turn): TrajectoryTurnModel[] => {
const groups = turn.groups.flatMap((group) => {
const cells = group.cells.filter(cell => overlaps(cell, range))
return cells.length === 0 ? [] : [{ ...group, cells }]
})
return groups.length === 0 ? [] : [{ ...turn, groups }]
})
}
/**
* Format a relative timeline offset with a compact unit.
* @param milliseconds - Non-negative relative offset.
* @returns Millisecond or second label.
*/
export function formatTimelineOffset(milliseconds: number): string {
if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
const seconds = milliseconds / 1_000
return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s`
}

View File

@@ -1,4 +1,4 @@
/* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */
/* Full-bleed, fixed-height host for the trajectory ledger. */
.root {
--dsh-trajectory-toolbar-height: 40px;
@@ -13,16 +13,6 @@
background: var(--dsw-alias-bg-layer-1);
}
.empty {
display: grid;
flex: 1;
margin: 0;
padding: 24px;
place-items: center;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
.ledger {
display: flex;
flex: 1;
@@ -30,68 +20,3 @@
min-width: 0;
overflow: hidden;
}
/* Waterfall placeholder rows (shared module). */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 16px;
}
.turnTag {
flex: none;
width: 64px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
}
.bar {
height: 12px;
border-radius: 4px;
background: var(--dsw-alias-bg-skeleton);
}
.barCalls {
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.meta {
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
}
/* run_code sub-span lanes: one row per sub-dispatch under its turn row,
offset/width scaled to the dispatch window (real wall time). A running
lane pulses via reduced opacity until its settle arrives. */
.subRow {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.subTag {
flex: none;
width: 88px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
.barSub {
height: 8px;
background: var(--dsw-alias-state-business-primary);
}
.barSub[data-timing='running'] {
opacity: 0.45;
}
/* Settle-only replay entries: no measured span — hollow, not a solid bar. */
.barSub[data-timing='unknown'] {
background: transparent;
border: 1px dashed var(--dsw-alias-state-business-primary);
}

View File

@@ -16,8 +16,8 @@ export const inject = ['invariants']
/**
* No runtime invariant: a pure-consumer plugin — it emits no cordis events
* and owns no mutable cross-plugin state; both view-slot registrations are
* plain effects whose disposal the slot ledger's own specs and this
* and owns no mutable cross-plugin state; its view-slot registration is a
* plain effect whose disposal the slot ledger's own specs and this
* package's behavior specs observe directly.
*/
const install: InvariantInstaller = () => {}

View File

@@ -3,7 +3,7 @@
* Real tsdown artifact shape: lib/client.js hands off through
* window.__ModuleLoader__.load, resolves externals through the injected
* require, returns the export surface (apply + inject), and a mounted apply
* registers both view tabs into a real SlotsService ring. Skips when dist/ is
* registers the view tab into a real SlotsService ring. Skips when dist/ is
* not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`).
*/
import { readFileSync } from 'node:fs'
@@ -63,7 +63,7 @@ describe('tsdown client artifact', () => {
expect(surface.inject).toEqual(['slots', 'conversation', 'sessions'])
})
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both view tabs on the real ring', async () => {
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
const { surface } = await loadArtifact()
const ctx = new Context()
const slots = new SlotsService(ctx)
@@ -78,7 +78,7 @@ describe('tsdown client artifact', () => {
ctx.provide('sessions', {})
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
await fiber.await()
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory', 'waterfall'])
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
await fiber.dispose()
expect(slots.entries('conversation.view')).toHaveLength(0)
})

View File

@@ -1,11 +1,11 @@
// @vitest-environment jsdom
/**
* View registration acceptance on the real framework stack: the plugin fiber
* registers trajectory/waterfall into a real SlotsService view ring, tabs
* registers Trajectory into a real SlotsService view ring, tabs
* switch inside ConversationRoot (renderSlot share driven by the same tab
* projection apply uses) without collapsing chat, trajectory renders the
* event-ledger chrome (no span stats bar), waterfall keeps in-body stats, and
* fiber disposal removes both tabs. Span derivation edge cases ride along.
* event ledger with its timing overview, and fiber disposal removes the tab.
* Timeline projection and inclusive filtering edge cases ride along.
*/
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -23,10 +23,13 @@ import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversa
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { deriveSpans, deriveSpanStats, deriveSubSpans } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
import type { TrajectoryTurnModel } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/layout.ts'
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
import {
deriveTrajectoryTimeline,
filterTrajectoryTimelineRange,
formatTimelineOffset,
} from '@deepseek-ai/dsh-client-ui-trajectory/src/client/timeline.ts'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
const SID = 's1' as SessionId
@@ -42,12 +45,18 @@ beforeEach(() => {
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
const NODES = [
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
{ kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] },
{
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null,
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [],
timing: { stepStartTime: 1_800, firstTokenTime: 1_900, completedTime: 2_000 },
},
{
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200,
content: [], isError: false, callView: null, resultView: null,
},
{ kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] },
{
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [],
timing: { stepStartTime: 3_500, firstTokenTime: 3_700, completedTime: 4_000 },
},
] as unknown as ConversationSnapshot['nodes']
function fakeSession(nodes: ConversationSnapshot['nodes']) {
@@ -167,16 +176,15 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
}
describe('plugin registration', () => {
it('registers trajectory and waterfall after chat on the ring', async () => {
it('registers trajectory after chat on the ring', async () => {
const b = await bench()
expect(tabsOf(b.slots)).toEqual([
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
{ id: 'waterfall', label: 'Waterfall' },
])
})
it('fiber disposal removes both tabs and leaves chat standing', async () => {
it('fiber disposal removes the tab and leaves chat standing', async () => {
const b = await bench()
await b.fiber.dispose()
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
@@ -184,17 +192,18 @@ describe('plugin registration', () => {
})
describe('tab switching in ConversationRoot', () => {
it('renders all three tabs, defaults to chat, and switches to the trajectory ledger', async () => {
it('renders two tabs, defaults to chat, and switches to the trajectory ledger', async () => {
const b = await bench()
const view = mount(b.slots)
expect(screen.getByTestId('chat-body')).toBeTruthy()
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory'])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.queryByText(/turns ·/)).toBeNull()
expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2)
expect(screen.queryByRole('columnheader')).toBeNull()
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' }))
expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
@@ -220,47 +229,72 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
})
it('waterfall renders bars and switching back to chat does not collapse it', async () => {
it('dragging the overview focuses overlapping records and clear restores the ledger', async () => {
const b = await bench()
mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
expect(screen.getByTitle('2 nodes')).toBeTruthy()
expect(screen.getByTitle('1 tool calls')).toBeTruthy()
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
const plot = screen.getByLabelText('Timeline overview; drag horizontally to filter events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 1 })
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
expect(screen.getByTestId('chat-body')).toBeTruthy()
expect(screen.queryByRole('row', { name: /USER/ })).toBeNull()
expect(screen.getByRole('button', { name: 'Clear selection' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Clear selection' }))
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
})
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
it('empty window keeps the toolbar and reports no timing data', async () => {
const b = await bench()
mount(b.slots, [])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.getByText('No timing data')).toBeTruthy()
expect(screen.queryByRole('row')).toBeNull()
expect(screen.queryByText(/turns ·/)).toBeNull()
})
})
describe('span derivation', () => {
it('attributes prologue to turn 0 and follows steering turn tags', () => {
const nodes = [
{ kind: 'user', seq: 1 },
{ kind: 'steering', seq: 2, turn: 5 },
{ kind: 'user', seq: 3 },
] as unknown as ConversationSnapshot['nodes']
const spans = deriveSpans(nodes)
expect(spans).toEqual([
{ turn: 0, steps: 0, calls: 0, nodes: 1 },
{ turn: 5, steps: 0, calls: 0, nodes: 2 },
])
expect(deriveSpanStats(spans)).toEqual({ turns: 2, steps: 0, calls: 0 })
describe('timeline projection', () => {
const turns = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [
{ index: 1, kind: 'message', text: 'assistant', startedAt: 1_000, timeSeconds: 1 },
{ index: 2, kind: 'tool', text: 'bash', startedAt: 2_000, timeSeconds: 1 },
{ index: 3, kind: 'user', text: 'unknown', timeSeconds: 0 },
],
}],
}] satisfies readonly TrajectoryTurnModel[]
it('uses real start/duration timing and stable semantic lanes', () => {
expect(deriveTrajectoryTimeline(turns)).toEqual({
start: 1_000,
end: 3_000,
spans: [
{
index: 1, kind: 'message', label: 'assistant', lane: 1, start: 1_000, end: 2_000,
},
{ index: 2, kind: 'tool', label: 'bash', lane: 2, start: 2_000, end: 3_000 },
],
})
expect(formatTimelineOffset(999)).toBe('999 ms')
expect(formatTimelineOffset(1_500)).toBe('1.5 s')
})
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 })
const { useSession } = fakeSession([])
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
expect(container.firstChild).toBeNull()
it('filters inclusively and drops records without known timing', () => {
const focused = filterTrajectoryTimelineRange(turns, { start: 2_000, end: 2_000 })
expect(focused[0]?.groups[0]?.cells.map(cell => cell.index)).toEqual([1, 2])
expect(filterTrajectoryTimelineRange(turns, null)).toBe(turns)
})
it('empty inputs produce no model and the standalone view reports its empty form', () => {
expect(deriveTrajectoryTimeline([])).toBeNull()
render(createElement(
TrajectoryView,
{ ...standaloneProps([]), loadAllHistory: () => Promise.resolve() },
@@ -343,135 +377,8 @@ describe('TrajectoryView branches', () => {
})
})
describe('WaterfallView standalone branches', () => {
it('empty window renders the placeholder copy', () => {
render(createElement(WaterfallView as FC<ConvViewProps>,
standaloneProps([])))
expect(screen.getByText('No timing data')).toBeTruthy()
})
it('a turn without tool calls renders the node bar only', () => {
const nodes = [{ kind: 'user', seq: 1 }] as unknown as ConversationSnapshot['nodes']
render(createElement(WaterfallView as FC<ConvViewProps>, standaloneProps(nodes)))
expect(screen.getByTitle('1 nodes')).toBeTruthy()
expect(screen.queryByTitle(/tool calls/)).toBeNull()
})
})
describe('node half', () => {
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
expect(() => { nodeApply() }).not.toThrow()
})
})
describe('deriveSubSpans (waterfall lanes)', () => {
const dispatchNodes = [
{ kind: 'assistant', seq: 2, time: 6_000, turn: 3, step: 1, blocks: [] },
{
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
call: { name: 'run_code', argsRaw: '{}' }, callTime: 6_100,
content: [], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
it('scales settled lanes into the dispatch window with real durations', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 7_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
content: [], isError: false, callView: null, resultView: null,
},
{
kind: 'tool-result', seq: 102, time: 8_200, callId: 'p1:code:2',
call: { name: 'read', argsRaw: '{}' }, callTime: 7_000,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
const turn3 = lanes.get(3)
expect(turn3).toHaveLength(2)
// Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0.
expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, timing: 'measured', offsetFraction: 0 })
expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4)
expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 })
expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4)
})
it('a running lane extends to the window end with a null duration', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
content: [], isError: false, callView: null, resultView: null,
},
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
const running = lanes.get(3)?.find(lane => lane.name === 'grep')
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
// Extends from its start to the window end.
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
})
it('a settle-only entry (null callTime) is unknown timing, never a measured 0 ms', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: null,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lane = deriveSubSpans(dispatchNodes, codeDispatches).get(3)?.[0]
expect(lane).toMatchObject({ durationMs: null, timing: 'unknown' })
})
it('waterfall renders sub-span lanes under the owning turn row', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const store = createSnapshotStore({
nodes: dispatchNodes, partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
})
const props = {
sessionId: SID,
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
} as unknown as ConvViewProps
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
const lane = view.container.querySelector('[data-subspan]')
expect(lane).not.toBeNull()
expect(lane!.textContent).toContain('bash')
expect(lane!.querySelector('[title*="1.80 s"]')).not.toBeNull()
expect(lane!.querySelector('[data-timing="measured"]')).not.toBeNull()
})
it('waterfall labels a settle-only lane as duration unknown', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'read', argsRaw: '{}' }, callTime: null,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const store = createSnapshotStore({
nodes: dispatchNodes, partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
})
const props = {
sessionId: SID,
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
} as unknown as ConvViewProps
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
const bar = view.container.querySelector('[data-timing="unknown"]')
expect(bar).not.toBeNull()
expect(bar!.getAttribute('title')).toContain('duration unknown')
})
})