Merge remote-tracking branch 'origin/master' into mergebot/pr908

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-trajectory/src/client/views.module.css
This commit is contained in:
imccyu
2026-07-29 22:15:22 +08:00
149 changed files with 10983 additions and 1002 deletions

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: 9e7dee8d5572baad13d7598d1fbde7b042dd5ab4
README.zh.md: 056827d0110b4360791f1ae08c9bf6f62c00f049
README.md: b9c8b849b3454fe46e1fc37713d9d3b9449734cf
README.zh.md: 19ae5050a4c4f7dfe80de0ab58e772e9d26e3f6a

View File

@@ -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. 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 runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. 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 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, 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) | 中文
轨迹轮次列表界面框架(吸顶 TurnMessage·Step 分组/步骤单元格)及 Waterfall瀑布式事件占位符这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录清除选择则恢复完整分支。runtime 的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包package保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
## 模型体验
@@ -14,4 +14,4 @@
## 已知限制与暂缓事项
- **进行中时Time 保持空白**`partial``runningCalls`在实时钟策略落地前渲染为 `—`;选中样式仅在当前视图内部生效(未连接到聊天详情);锚点深链接仍暂缓实现。
- **进行中时Time 保持空白**`partial``runningCalls`会显示运行状态,但在实时钟策略落地前不会虚构耗时,因此 Overview 区域只渲染开始标记,而不会杜撰实时跨度;记录选择与时间线选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。

View File

@@ -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 with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -33,13 +33,18 @@
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"diff": "^9.0.0"
},
"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:^",

View File

@@ -46,14 +46,40 @@
white-space: nowrap;
}
.tagSystem {
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-module-platform);
}
.tagUser {
color: var(--dsw-alias-state-success-primary);
background: var(--dsw-alias-state-success-tertiary);
}
.tagContext {
color: color-mix(
in srgb,
var(--dsw-alias-state-success-primary) 68%,
var(--dsw-alias-label-secondary)
);
background: var(--dsw-alias-state-success-tertiary);
}
.tagMessage {
color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
background: var(--dsw-specific-bubble);
color: color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
var(--dsw-alias-state-error-secondary)
);
background: color-mix(
in srgb,
color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 55%,
var(--dsw-alias-state-error-secondary)
) 15%,
var(--dsw-alias-bg-layer-1)
);
}
.tagTool {
@@ -64,8 +90,16 @@
/* run_code sub-dispatch cells: the business tint plus an indent so the
nesting under the parent Tool cell reads at a glance. */
.tagSubtool {
color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-state-business-tertiary);
color: color-mix(
in srgb,
var(--dsw-alias-state-warn-label) 62%,
var(--dsw-alias-label-tertiary)
);
background: color-mix(
in srgb,
var(--dsw-alias-state-warn-tertiary) 58%,
var(--dsw-alias-bg-layer-1)
);
}
.root[data-kind='subtool'] {

View File

@@ -1,62 +1,40 @@
// 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> = {
system: 'System',
user: 'User',
context: 'Context',
compacted: 'Compacted',
message: 'Message',
tool: 'Tool',
subtool: 'Sub',
}
const TAG_CLASS: Record<TrajectoryCellKind, string | undefined> = {
system: css.tagSystem,
user: css.tagUser,
context: css.tagContext,
compacted: css.tagSystem,
message: css.tagMessage,
tool: css.tagTool,
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 +44,20 @@ export function TrajectoryCell({
index,
kind,
text,
inputDetail: _inputDetail,
promptDetail: _promptDetail,
previousPromptDetail: _previousPromptDetail,
outputDetail: _outputDetail,
thinkingDetail: _thinkingDetail,
sourceBlocks: _sourceBlocks,
outputBlocks: _outputBlocks,
schemaDetail: _schemaDetail,
assistantMetrics: _assistantMetrics,
result: _result,
callId: _callId,
isError: _isError,
timeSeconds,
startedAt: _startedAt,
input,
output,
think,

View File

@@ -5,7 +5,7 @@ import css from './TrajectoryGroupHeader.module.css'
export interface TrajectoryGroupHeaderProps {
/** Group title (`Message`, `Step 1`, …). */
title: string
/** Secondary summary (`49s`, `2.2s skill`, …). */
/** Secondary summary (`49 s`, `2.2 s skill`, …). */
description?: string
}

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>
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,228 @@
.root {
flex: none;
border-bottom: 1px solid var(--dsw-alias-border-l2);
user-select: none;
}
.plot {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
height: 50px;
overflow: hidden;
background: var(--dsw-alias-bg-layer-2);
}
.labels {
position: relative;
border-right: 1px solid var(--dsw-alias-border-l1);
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
font-size: 10px;
line-height: 1;
}
.labels span {
position: absolute;
right: 3px;
display: flex;
align-items: center;
justify-content: flex-end;
height: 8px;
text-align: right;
}
.labels span:nth-child(1) {
top: 7px;
}
.labels span:nth-child(2) {
top: 21px;
}
.labels span:nth-child(3) {
top: 35px;
}
.track {
position: relative;
overflow: hidden;
cursor: crosshair;
touch-action: none;
}
.empty {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
}
.track:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: -1px;
}
.lanes {
position: absolute;
z-index: 2;
inset: 7px 0;
}
.turnBoundaries {
position: absolute;
z-index: 3;
inset: 0;
pointer-events: none;
}
.turnBoundary {
position: absolute;
top: 0;
bottom: 0;
left: var(--trajectory-turn-left);
width: 1px;
background: var(--dsw-alias-border-l2);
}
.span {
position: absolute;
top: calc(var(--trajectory-span-lane) * 14px);
left: calc(var(--trajectory-span-left) + 1px);
width: max(2px, calc(var(--trajectory-span-width) - 2px));
height: 8px;
min-width: 2px;
border-radius: 1px;
background: var(--dsw-alias-label-secondary);
opacity: 0.78;
}
.span[data-timeline-span='user'] {
background: var(--dsw-alias-state-business-primary);
}
.span[data-timeline-span='context'] {
background: color-mix(
in srgb,
var(--dsw-alias-state-success-primary) 68%,
var(--dsw-alias-label-secondary)
);
}
.span[data-timeline-span='message'] {
background: color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
var(--dsw-alias-state-error-secondary)
);
}
.span[data-timeline-span='tool'] {
background: var(--dsw-alias-state-warn-label);
}
.span[data-timeline-span='subtool'] {
background: color-mix(
in srgb,
var(--dsw-alias-state-warn-label) 62%,
var(--dsw-alias-label-tertiary)
);
}
.span[data-equal-duration='true'] {
width: 8px;
min-width: 8px;
}
.span[data-selected='false'] {
opacity: 0.2;
}
.span[data-current='true'] {
z-index: 1;
opacity: 1;
box-shadow:
0 0 0 1px var(--dsw-alias-bg-layer-2),
0 0 0 2px var(--dsw-alias-state-business-primary);
}
.span[data-search-match='false'] {
opacity: 0.14;
}
.selection {
position: absolute;
z-index: 1;
top: 0;
bottom: 0;
left: var(--trajectory-selection-left);
width: var(--trajectory-selection-width);
min-width: 1px;
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;
}
.selectionEdges {
position: absolute;
z-index: 4;
top: 0;
bottom: 0;
left: var(--trajectory-selection-left);
width: var(--trajectory-selection-width);
min-width: 1px;
pointer-events: none;
}
.hoverLine {
position: absolute;
z-index: 4;
top: 0;
bottom: 0;
left: clamp(
0px,
calc(var(--trajectory-hover-left) - 1px),
calc(100% - 2px)
);
width: 2px;
background: var(--dsw-alias-state-business-primary);
pointer-events: none;
}
.selectionEdges::before,
.selectionEdges::after {
position: absolute;
top: 0;
bottom: 0;
width: 3px;
background: var(--dsw-alias-state-business-primary);
content: '';
}
.selectionEdges::before {
left: 0;
}
.selectionEdges::after {
right: 0;
}
.selectionEdges[data-dragging='true']::before,
.selectionEdges[data-dragging='true']::after {
width: 2px;
}
.selection[data-dragging='true'] {
background: color-mix(
in srgb,
var(--dsw-alias-state-business-primary) 18%,
transparent
);
}

View File

@@ -0,0 +1,372 @@
/** Chrome-Network-style overview timeline for focusing the trajectory ledger. */
import {
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
type PointerEvent, type WheelEvent,
} from 'react'
import type { TrajectoryTurnModel } from './layout.ts'
import {
deriveTrajectoryTimeline,
formatTimelineOffset,
type TrajectoryTimelineMode,
type TrajectoryTimeRange,
} from './timeline.ts'
import css from './TrajectoryTimeline.module.css'
const MINIMUM_DRAG_PX = 3
const MINIMUM_ZOOM_OPERATIONS = 4
interface FractionRange {
start: number
end: number
}
/** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
mode: TrajectoryTimelineMode
range: TrajectoryTimeRange | null
selectedIndex?: number | null
/** Record indexes matching the active ledger search, or null without a query. */
searchMatchIndexes?: ReadonlySet<number> | null
onRangeChange: (range: TrajectoryTimeRange | null) => void
onRecordFocus?: (index: number) => 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 centeredRange(center: number, width: number): FractionRange {
const clampedWidth = Math.min(1, Math.max(0, width))
const start = Math.min(
Math.max(center - clampedWidth / 2, 0),
1 - clampedWidth,
)
return { start, end: start + clampedWidth }
}
function rangeFraction(
range: TrajectoryTimeRange,
start: number,
duration: number,
): FractionRange {
return orderedRange(
clampFraction((range.start - start) / duration),
clampFraction((range.end - start) / duration),
)
}
function LaneLabels() {
return (
<div className={css.labels} aria-hidden="true">
<span>Input</span>
<span>Model</span>
<span>Tools</span>
</div>
)
}
/** Overview renderer with drag ranges, click-sized focus, and Escape reset. */
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
turns,
mode,
range,
selectedIndex = null,
searchMatchIndexes = null,
onRangeChange,
onRecordFocus,
}: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
const durationByIndex = useMemo(
() => new Map(turns.flatMap(turn =>
turn.groups.flatMap(group =>
group.cells.flatMap(cell =>
cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds)
? []
: [[cell.index, Math.max(0, cell.timeSeconds * 1_000)] as const],
),
),
)),
[turns],
)
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
const [draft, setDraft] = useState<FractionRange | null>(null)
const [hover, setHover] = useState<number | null>(null)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
useEffect(() => {
if (
model !== null
&& range !== null
&& (range.end < model.start || range.start > model.end)
) {
onRangeChange(null)
}
}, [model, onRangeChange, range])
useEffect(() => {
if (model === null) return
setViewport(current =>
current !== null && (current.end < model.start || current.start > model.end)
? null
: current)
}, [model])
const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
const viewportDuration = Math.min(
fullDuration,
Math.max(1, (viewport?.end ?? 0) - (viewport?.start ?? 0)),
)
const viewportStart = model === null || viewport === null
? model?.start ?? 0
: Math.min(
Math.max(viewport.start, model.start),
model.end - viewportDuration,
)
const domainDuration = viewport === null ? fullDuration : viewportDuration
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
const committed = model === null || range === null
? null
: rangeFraction(range, domainStart, domainDuration)
const visibleRange = draft ?? committed
const activeRange = draft === null
? range
: {
start: domainStart + draft.start * domainDuration,
end: domainStart + draft.end * domainDuration,
}
if (model === null) {
return (
<section className={css.root} aria-label="Trajectory timeline">
<div className={css.plot}>
<LaneLabels />
<div className={css.track}>
<span className={css.empty}>No timing data</span>
</div>
</div>
</section>
)
}
const minimumSelectionFraction = Math.min(
1,
fullDuration / domainDuration / model.spans.length,
)
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: domainStart + fraction.start * domainDuration,
end: domainStart + fraction.end * domainDuration,
})
}
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
const rect = event.currentTarget.getBoundingClientRect()
const anchor = fractionAt(event)
setHover(anchor)
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
const fraction = fractionAt(event)
setHover(fraction)
if (drag === null || drag.pointerId !== event.pointerId) return
setDraft(orderedRange(drag.anchor, fraction))
}
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
const point = fractionAt(event)
const selected = orderedRange(drag.anchor, point)
setHover(point)
dragRef.current = null
setDraft(null)
const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX
const committedRange = selected.end - selected.start < minimumSelectionFraction
? centeredRange(
click ? selected.start : (selected.start + selected.end) / 2,
minimumSelectionFraction,
)
: selected
commit(committedRange)
if (click) {
const timelinePoint = domainStart + selected.start * domainDuration
const nearest = model.spans.reduce((candidate, span) => {
const candidateDistance = timelinePoint < candidate.start
? candidate.start - timelinePoint
: timelinePoint > candidate.end ? timelinePoint - candidate.end : 0
const spanDistance = timelinePoint < span.start
? span.start - timelinePoint
: timelinePoint > span.end ? timelinePoint - span.end : 0
return spanDistance < candidateDistance ? span : candidate
})
onRecordFocus?.(nearest.index)
}
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Escape' || range === null) return
event.preventDefault()
onRangeChange(null)
}
const onPointerCancel = () => {
dragRef.current = null
setDraft(null)
setHover(null)
}
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
const anchorFraction =
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
const nextDuration = Math.min(
fullDuration,
Math.max(
Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
domainDuration * Math.exp(event.deltaY * 0.0015),
),
)
if (nextDuration >= fullDuration * 0.999) {
setViewport(null)
return
}
const anchorTime = domainStart + anchorFraction * domainDuration
const nextStart = Math.min(
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
model.end - nextDuration,
)
setViewport({ start: nextStart, end: nextStart + nextDuration })
}
return (
<section className={css.root} aria-label="Trajectory timeline">
<div className={css.plot}>
<LaneLabels />
<div
className={css.track}
aria-label="Timeline overview; drag horizontally to focus events"
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerCancel}
onPointerLeave={() => {
if (dragRef.current === null) setHover(null)
}}
onDoubleClick={(event) => {
event.preventDefault()
onRangeChange(null)
}}
onWheel={onWheel}
onContextMenu={(event) => {
event.preventDefault()
onRangeChange(null)
setViewport(null)
}}
>
{hover !== null && draft === null && (
<div
className={css.hoverLine}
aria-hidden="true"
style={{
'--trajectory-hover-left': `${hover * 100}%`,
} as CSSProperties}
/>
)}
{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
className={css.selectionEdges}
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 className={css.turnBoundaries} aria-hidden="true">
{model.turnBoundaries
.slice(1)
.filter(boundary =>
boundary.time >= domainStart
&& boundary.time <= domainStart + domainDuration)
.map(boundary => (
<span
className={css.turnBoundary}
data-turn={boundary.turn}
key={boundary.turn}
style={{
'--trajectory-turn-left':
`${(boundary.time - domainStart) / domainDuration * 100}%`,
} as CSSProperties}
/>
))}
</div>
<div className={css.lanes} aria-hidden="true">
{model.spans
.filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
.map((span) => {
const left = (span.start - domainStart) / domainDuration
const width = (span.end - span.start) / domainDuration
const durationMs = durationByIndex.get(span.index)
return (
<span
className={css.span}
data-timeline-span={span.kind}
data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined}
data-search-match={searchMatchIndexes === null
? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
data-selected={activeRange === null
? undefined
: span.start <= activeRange.end && span.end >= activeRange.start
? 'true'
: 'false'}
key={span.index}
title={durationMs === undefined
? span.label
: `${span.label} · ${formatTimelineOffset(durationMs)}`}
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
'--trajectory-span-lane': span.lane,
} as CSSProperties}
/>
)
})}
</div>
</div>
</div>
</section>
)
})

View File

@@ -0,0 +1,219 @@
.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;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 0 6px;
gap: 8px;
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: 2px;
}
.toggle {
display: inline-flex;
flex: none;
align-items: center;
height: 20px;
padding: 0 7px;
gap: 4px;
border: 0;
border-radius: 3px;
color: var(--dsw-alias-label-tertiary);
background: transparent;
cursor: pointer;
font: var(--dsw-font-xxs-12);
}
.toggle:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.toggle[aria-pressed='true'] {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.toggle:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
.toggleIcon {
flex: none;
width: 12px;
height: 12px;
stroke: currentColor;
stroke-width: 1.25;
stroke-linecap: round;
stroke-linejoin: round;
}
.control {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 88px;
height: 20px;
padding: 0 5px;
gap: 4px;
border: 0;
border-radius: 0;
color: var(--dsw-alias-label-tertiary);
background: transparent;
cursor: pointer;
font: var(--dsw-font-xxs-12);
}
.control[hidden] {
display: none;
}
.control:hover:not(:disabled),
.control[aria-checked='true'],
.control[aria-pressed='true'] {
color: var(--dsw-alias-label-primary);
}
.control:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
.control:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: not-allowed;
}
.controlTrack {
position: relative;
display: inline-block;
flex: none;
width: 20px;
height: 10px;
border-radius: 5px;
background: var(--dsw-alias-border-l2);
transition: background-color 120ms var(--ds-ease-in-out);
}
.controlThumb {
position: absolute;
top: 2px;
left: 2px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--dsw-alias-bg-layer-1);
transition: transform 120ms var(--ds-ease-in-out);
}
.controlTrack[data-on='true'] {
background: var(--dsw-alias-state-business-primary);
}
.controlTrack[data-on='true'] .controlThumb {
transform: translateX(10px);
}
.action {
display: inline-flex;
flex: none;
align-items: center;
height: 20px;
padding: 0 5px;
gap: 4px;
border: 0;
border-radius: 3px;
color: var(--dsw-alias-label-tertiary);
background: transparent;
cursor: pointer;
font: var(--dsw-font-xxs-12);
}
.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: 1px;
}
.action:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: not-allowed;
}
.actionIcon {
color: var(--dsw-alias-label-tertiary);
font: 14px/14px var(--ds-font-family-code);
}
.search {
display: flex;
flex: 0 1 164px;
align-items: center;
min-width: 84px;
height: 22px;
margin-left: auto;
padding: 0 6px;
gap: 4px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
color: var(--dsw-alias-label-caption);
background: var(--dsw-alias-bg-layer-2);
}
.search:hover {
border-color: var(--dsw-alias-label-caption);
}
.search:focus-within {
border-color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-bg-layer-1);
}
.searchIcon {
flex: none;
}
.searchInput {
min-width: 0;
width: 100%;
padding: 0;
border: 0;
outline: 0;
color: var(--dsw-alias-label-primary);
background: transparent;
font: var(--dsw-font-xxs-12);
}
.searchInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.searchInput::-webkit-search-cancel-button {
width: 12px;
height: 12px;
cursor: pointer;
}

View File

@@ -0,0 +1,131 @@
/** Trajectory toolbar: timeline and ledger fold controls. */
import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './TrajectoryToolbar.module.css'
export interface TrajectoryToolbarProps {
/** Whether timeline blocks use recorded durations instead of equal widths. */
actualDuration: boolean
/** Select recorded-duration or equal-width blocks. */
onActualDurationChange: (actualDuration: boolean) => void
/** Whether recorded timing retains idle gaps between user turns. */
actualTime: boolean
/** Select complete wall-clock timing or idle-compressed timing. */
onActualTimeChange: (actualTime: boolean) => void
/** 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
/** Current live ledger search query. */
searchQuery: string
/** Update the live ledger search query. */
onSearchQueryChange: (query: string) => void
}
/**
* Render the sticky trajectory toolbar.
* @param props - rendered counts and whole-list fold state.
* @returns the toolbar element.
*/
export function TrajectoryToolbar({
actualDuration,
onActualDurationChange,
actualTime,
onActualTimeChange,
collapsibleTurns,
allTurnsCollapsed,
onToggleAllTurns,
collapsibleAssistants,
allAssistantsCollapsed,
onToggleAllAssistants,
searchQuery,
onSearchQueryChange,
}: TrajectoryToolbarProps) {
return (
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
<div className={css.inner}>
<div className={css.actions}>
<button
type="button"
className={css.toggle}
aria-label="Use actual duration"
aria-pressed={actualDuration}
title={actualDuration ? 'Use equal-width operations' : 'Use actual duration'}
onClick={() => { onActualDurationChange(!actualDuration) }}
>
<svg
className={css.toggleIcon}
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle cx="8" cy="8" r="5.25" />
<path d="M8 4.75V8l2.25 1.5" />
</svg>
Duration
</button>
<button
type="button"
className={css.control}
role="switch"
aria-checked={actualTime}
hidden
onClick={() => { onActualTimeChange(!actualTime) }}
>
<span>Actual time</span>
<span className={css.controlTrack} data-on={actualTime || undefined} aria-hidden="true">
<span className={css.controlThumb} />
</span>
</button>
<button
type="button"
className={css.action}
aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
aria-pressed={allTurnsCollapsed}
title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
disabled={collapsibleTurns === 0}
onClick={onToggleAllTurns}
>
<span className={css.actionIcon} aria-hidden="true">
{allTurnsCollapsed ? '⊞' : '⊟'}
</span>
Turns
</button>
<button
type="button"
className={css.action}
aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
aria-pressed={allAssistantsCollapsed}
title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
disabled={collapsibleAssistants === 0}
onClick={onToggleAllAssistants}
>
<span className={css.actionIcon} aria-hidden="true">
{allAssistantsCollapsed ? '⊞' : '⊟'}
</span>
Calls
</button>
</div>
<div className={css.search}>
<IconSearchOutline16 size={11} className={css.searchIcon} />
<input
type="search"
className={css.searchInput}
aria-label="Search trajectory"
placeholder="Search"
value={searchQuery}
onChange={(event) => { onSearchQueryChange(event.currentTarget.value) }}
/>
</div>
</div>
</div>
)
}

View File

@@ -1,41 +1,509 @@
// 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 { useCallback, useEffect, useMemo, useRef, 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 type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type {
AssistantMessageNode, ConversationContext,
SessionHistoryFace,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
} from './context-branches.ts'
import {
TrajectoryTable,
type TrajectoryRequestNumber,
type TrajectoryUsage,
} from './TrajectoryTable.tsx'
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
import { deriveTrajectoryLayout } from './layout.ts'
import {
trajectoryTimelineFocusIndexes,
type TrajectoryTimelineMode,
type TrajectoryTimeRange,
} from './timeline.ts'
import css from './views.module.css'
export function TrajectoryView({ useSession }: ConvViewProps) {
const nodes = useSession(s => s.nodes)
const partial = useSession(s => s.partial)
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const turns = useMemo(
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
[nodes, partial, runningCalls, codeDispatches],
)
if (turns.length === 0) {
return <div className={css.root}><p className={css.empty}></p></div>
const EMPTY_IDS: ReadonlySet<number> = new Set()
/** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
hooks: { history: SessionHistoryFace }
loadAllHistory: (signal: AbortSignal) => Promise<void>
}
interface UsageLike {
inputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
outputTokens?: number
reasoningTokens?: number
}
function requestUsage(value: unknown): TrajectoryUsage | undefined {
const usage = value as UsageLike | undefined
if (usage === undefined) return undefined
return {
...(usage.inputTokens === undefined ? {} : { input: usage.inputTokens }),
...(usage.cacheReadTokens === undefined ? {} : { cacheRead: usage.cacheReadTokens }),
...(usage.cacheWriteTokens === undefined ? {} : { cacheWrite: usage.cacheWriteTokens }),
...(usage.outputTokens === undefined ? {} : { output: usage.outputTokens }),
...(usage.reasoningTokens === undefined ? {} : { reasoning: usage.reasoningTokens }),
}
}
function addUsage(
total: TrajectoryUsage | undefined,
usage: TrajectoryUsage | undefined,
): TrajectoryUsage | undefined {
if (usage === undefined) return total
return {
...(total?.input === undefined && usage.input === undefined
? {}
: { input: (total?.input ?? 0) + (usage.input ?? 0) }),
...(total?.cacheRead === undefined && usage.cacheRead === undefined
? {}
: { cacheRead: (total?.cacheRead ?? 0) + (usage.cacheRead ?? 0) }),
...(total?.cacheWrite === undefined && usage.cacheWrite === undefined
? {}
: { cacheWrite: (total?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0) }),
...(total?.output === undefined && usage.output === undefined
? {}
: { output: (total?.output ?? 0) + (usage.output ?? 0) }),
...(total?.reasoning === undefined && usage.reasoning === undefined
? {}
: { reasoning: (total?.reasoning ?? 0) + (usage.reasoning ?? 0) }),
}
}
function searchableJson(value: unknown): string {
if (value === undefined) return ''
try {
return JSON.stringify(value)
} catch {
return ''
}
}
function searchMatches(
turns: ReturnType<typeof deriveTrajectoryLayout>,
query: string,
): ReadonlySet<number> | null {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return null
const matches = new Set<number>()
for (const turn of turns) {
for (const group of turn.groups) {
for (const cell of group.cells) {
if (cell.requestOnly === true) continue
const blocks = [
...(cell.sourceBlocks ?? []),
...(cell.outputBlocks ?? []),
]
const text = [
`turn ${turn.turn}`,
group.title,
cell.kind,
cell.kind === 'message' ? 'assistant' : undefined,
cell.text,
cell.inputDetail,
cell.outputDetail,
cell.thinkingDetail,
cell.schemaDetail,
cell.result,
cell.callId,
...blocks.flatMap(block => [
block.type,
block.content,
block.callId,
block.toolName,
block.imageAlt,
]),
searchableJson(cell.messageSource),
searchableJson(cell.promptDetail),
searchableJson(cell.previousPromptDetail),
].filter((value): value is string => typeof value === 'string')
.join('\n')
.toLocaleLowerCase()
if (terms.every(term => text.includes(term))) matches.add(cell.index)
}
}
}
return matches
}
export function TrajectoryView({
useHistory, loadAllHistory,
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
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 [actualDuration, setActualDuration] = useState(false)
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
const ledgerRef = useRef<HTMLDivElement>(null)
const inspection = useHistory(snapshot => snapshot.inspection)
const nodes = inspection.eventNodes
const partial = inspection.partial
const runningCalls = inspection.runningCalls
const codeDispatches = inspection.codeDispatches
const loadAllHistoryRef = useRef(loadAllHistory)
loadAllHistoryRef.current = loadAllHistory
useEffect(() => {
const controller = new AbortController()
void loadAllHistoryRef.current(controller.signal)
return () => { controller.abort() }
}, [])
const requests = inspection.requests
const callSchemas = inspection.callSchemas
const contexts = useMemo<readonly ConversationContext[]>(
() => inspection.contexts.length === 0
? [{ id: 0, nodes }]
: inspection.contexts,
[inspection, nodes],
)
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
[contexts],
)
const currentBranch = branches.at(-1)
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = useMemo(() => {
const selected = new Map(currentBranch.nodes.map(node => [node.seq, node]))
for (const node of inspection.interruptedNodes) {
selected.set(node.seq, node)
}
return [...selected.values()].sort((left, right) => left.seq - right.seq)
}, [currentBranch, inspection])
const selectedRequests = useMemo(
() => requests.filter(request =>
trajectoryBranchContainsRequest(currentBranch, request),
),
[currentBranch, requests],
)
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
for (const node of context.nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
}
for (const node of nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
const requestsByStep = new Map(
requests
.filter(request => request.purpose === 'assistant')
.map(request => [
`${request.turn}\u0000${request.step}`,
request,
]),
)
const orderedRequests = [
...requests.map(request => ({
seq: request.startSeq,
request,
node: request.purpose === 'assistant'
? assistantsByStep.get(`${request.turn}\u0000${request.step}`)
: undefined,
})),
...[...assistantsByStep.entries()].flatMap(([key, node]) =>
requestsByStep.has(key)
? []
: [{
seq: node.seq,
request: undefined,
node,
}],
),
].sort((left, right) => left.seq - right.seq)
const numbered: TrajectoryRequestNumber[] = []
let cumulativeUsage: TrajectoryUsage | undefined
for (const [index, entry] of orderedRequests.entries()) {
const usage = requestUsage(entry.request?.usage ?? entry.node?.usage)
cumulativeUsage = addUsage(cumulativeUsage, usage)
if (entry.request?.purpose !== 'compaction') {
const request = entry.request
const node = entry.node
const turn = request?.turn ?? node?.turn
const step = request?.step ?? node?.step
if (turn === undefined || step === undefined) continue
const provider = request?.provenance?.provider ?? node?.provenance?.provider
const model = request?.provenance?.model ?? node?.provenance?.model
const requestConfig = request?.requestConfig ?? node?.requestConfig
numbered.push({
seq: entry.seq,
turn,
step,
group: `Step ${step}`,
number: index + 1,
...(request?.status === undefined ? {} : { status: request.status }),
...(request?.startedAt === undefined ? {} : { startedAt: request.startedAt }),
...(request?.completedAt === undefined ? {} : { completedAt: request.completedAt }),
...(request?.error === undefined ? {} : { error: request.error }),
...(request?.resultSeq === undefined ? {} : { resultSeq: request.resultSeq }),
...(request?.retry === undefined ? {} : { retry: request.retry }),
...(request?.maxRetries === undefined ? {} : { maxRetries: request.maxRetries }),
...(request?.retryDelayMs === undefined
? {}
: { retryDelayMs: request.retryDelayMs }),
...(provider === undefined ? {} : { provider }),
...(model === undefined ? {} : { model }),
...(requestConfig === undefined ? {} : { requestConfig }),
...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
continue
}
const request = entry.request
numbered.push({
seq: request.startSeq,
turn: request.turn,
step: 0,
group: `Compaction ${request.startSeq}`,
number: index + 1,
purpose: 'compaction',
status: request.status,
startedAt: request.startedAt,
completedAt: request.completedAt,
...(request.error === undefined ? {} : { error: request.error }),
resultSeq: request.startSeq,
...(request.provenance?.provider === undefined
? {}
: { provider: request.provenance.provider }),
...(request.provenance?.model === undefined
? {}
: { model: request.provenance.model }),
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
}
if (partial !== null && partial.step > 0) {
const key = `${partial.turn}\u0000${partial.step}`
const recorded = numbered.some(request =>
`${request.turn}\u0000${request.step}` === key,
)
if (!recorded) {
numbered.push({
turn: partial.turn,
step: partial.step,
group: `Step ${partial.step}`,
number: orderedRequests.length + 1,
...(currentBranch.latest.prompt?.config.provider === undefined
? {}
: { provider: currentBranch.latest.prompt.config.provider }),
...(currentBranch.latest.prompt?.config.model === undefined
? {}
: { model: currentBranch.latest.prompt.config.model }),
...(currentBranch.latest.prompt?.config === undefined
? {}
: { requestConfig: currentBranch.latest.prompt.config }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
}
}
return numbered
}, [
contexts, currentBranch.latest.prompt, nodes, partial, requests,
])
const requestNumbers = globalRequestNumbers
const turns = useMemo(
() => deriveTrajectoryLayout({
nodes: selectedNodes,
partial,
runningCalls,
requests: selectedRequests,
callSchemas,
codeDispatches,
}),
[
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
],
)
const timelineMode: TrajectoryTimelineMode = actualDuration
? actualTime ? 'actual' : 'duration'
: actualTime ? 'time' : 'sequence'
const searchMatchIndexes = useMemo(
() => searchMatches(turns, searchQuery),
[searchQuery, turns],
)
const timelineRange = timelineSelection?.branchId === currentBranch.id
? timelineSelection.range
: null
const timelineFocusIndexes = useMemo(
() => timelineRange === null
? null
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
[timelineMode, timelineRange, turns],
)
const handleRecordSelect = useCallback((index: number) => {
if (
timelineFocusIndexes !== null
&& !timelineFocusIndexes.has(index)
) {
setTimelineSelection(null)
}
}, [timelineFocusIndexes])
useEffect(() => {
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
const ledger = ledgerRef.current
if (ledger === null) return
const focusedRows = [
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
]
const first = focusedRows.at(0)
const last = focusedRows.at(-1)
if (first === undefined || last === undefined) return
const focusHeight =
last.getBoundingClientRect().bottom - first.getBoundingClientRect().top
if (focusHeight > ledger.clientHeight) {
if (typeof first.scrollIntoView === 'function') {
first.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return
}
const middle = focusedRows[Math.floor((focusedRows.length - 1) / 2)]
if (middle !== undefined && typeof middle.scrollIntoView === 'function') {
middle.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, [timelineFocusIndexes])
const collapsibleTurnIds = useMemo(
() => turns
.filter(turn =>
turn.groups.reduce(
(count, group) =>
count + group.cells.filter(cell =>
cell.requestOnly !== true && cell.kind !== 'system').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 collapsed = new Set(current)
if (collapsed.has(turn)) collapsed.delete(turn)
else collapsed.add(turn)
return collapsed
})
}
const toggleAllTurns = () => {
setCollapsedTurns((current) => {
const collapsed = new Set(current)
if (allTurnsCollapsed) {
for (const turn of collapsibleTurnIds) collapsed.delete(turn)
} else {
for (const turn of collapsibleTurnIds) collapsed.add(turn)
}
return collapsed
})
}
const toggleAssistant = (index: number) => {
setCollapsedAssistants((current) => {
const collapsed = new Set(current)
if (collapsed.has(index)) collapsed.delete(index)
else collapsed.add(index)
return collapsed
})
}
const toggleAllAssistants = () => {
setCollapsedAssistants((current) => {
const collapsed = new Set(current)
if (allAssistantsCollapsed) {
for (const index of collapsibleAssistantIds) collapsed.delete(index)
} else {
for (const index of collapsibleAssistantIds) collapsed.add(index)
}
return collapsed
})
}
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
actualDuration={actualDuration}
onActualDurationChange={(nextActualDuration) => {
setActualDuration(nextActualDuration)
setTimelineSelection(null)
}}
actualTime={actualTime}
onActualTimeChange={(nextActualTime) => {
setActualTime(nextActualTime)
setTimelineSelection(null)
}}
collapsibleTurns={collapsibleTurnIds.length}
allTurnsCollapsed={allTurnsCollapsed}
onToggleAllTurns={toggleAllTurns}
collapsibleAssistants={collapsibleAssistantIds.length}
allAssistantsCollapsed={allAssistantsCollapsed}
onToggleAllAssistants={toggleAllAssistants}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
/>
<TrajectoryTimeline
turns={turns}
mode={timelineMode}
range={timelineRange}
selectedIndex={selectedTimelineIndex}
searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => {
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
}}
onRecordFocus={(index) => {
const row = ledgerRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
/>
<div ref={ledgerRef} className={css.ledger}>
<TrajectoryTable
key={currentBranch.id}
requestNumbers={requestNumbers}
turns={turns}
timelineFocusIndexes={timelineFocusIndexes}
searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect}
onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
onToggleAssistant={toggleAssistant}
/>
</div>
</div>
)
}

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}></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

@@ -0,0 +1,113 @@
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
import type {
ConversationContext, ConversationNode, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
export interface TrajectoryContextBranch {
id: number
contexts: readonly ConversationContext[]
latest: ConversationContext
nodes: readonly ConversationNode[]
/** Seq that opened this branch; earlier requests require retained surface provenance. */
startSeq: number
/** Exact pre-rewind surface records inherited by this branch. */
retainedSurfaceSeqs: ReadonlySet<number>
}
interface MutableBranch {
id: number
contexts: ConversationContext[]
latest: ConversationContext
nodes: Map<number, ConversationNode>
startSeq: number
retainedSurfaceSeqs: Set<number>
}
function isCompactionCheckpoint(node: ConversationNode): boolean {
if (node.kind !== 'context') return false
const source = node.source
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}
/**
* Join context generations across compaction/rewrite operations and split only at rewind.
* @param contexts - Append-only context generations from the runtime fold.
* @returns Rewind-delimited branches in creation order.
*/
export function deriveTrajectoryContextBranches(
contexts: readonly ConversationContext[],
): readonly TrajectoryContextBranch[] {
const mutable: MutableBranch[] = []
for (const context of contexts) {
const startsBranch = mutable.length === 0 || context.origin === 'rewind'
if (startsBranch) {
const previous = mutable.at(-1)
const retainedSurfaceSeqs = new Set(
context.nodes
.filter(node =>
context.originSeq !== undefined && node.seq < context.originSeq,
)
.map(node => node.seq),
)
const inheritedNodes = previous === undefined
? []
: [...previous.nodes.values()].filter(node =>
retainedSurfaceSeqs.has(node.seq),
)
mutable.push({
id: context.id,
contexts: [context],
latest: context,
nodes: new Map(
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
.map(node => [node.seq, node]),
),
startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY,
retainedSurfaceSeqs,
})
continue
}
const branch = mutable.at(-1)
if (branch === undefined) continue
branch.contexts.push(context)
branch.latest = context
for (const node of context.nodes) {
if (!isCompactionCheckpoint(node)) branch.nodes.set(node.seq, node)
}
}
return mutable.map(branch => ({
id: branch.id,
contexts: branch.contexts,
latest: branch.latest,
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),
startSeq: branch.startSeq,
retainedSurfaceSeqs: branch.retainedSurfaceSeqs,
}))
}
/**
* Test whether a provider request belongs to one rewind branch.
* @param branch - Branch carrying exact inherited surface provenance.
* @param request - Provider request to classify.
* @returns Whether the request began on this branch or produced a retained surface record.
*/
export function trajectoryBranchContainsRequest(
branch: TrajectoryContextBranch,
request: RequestView,
): boolean {
if (request.startSeq >= branch.startSeq) return true
return (
request.resultSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.resultSeq)
) || (
request.replacementSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.replacementSeq)
)
}

View File

@@ -1,13 +1,13 @@
/**
* 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'
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
// 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 } from './TrajectoryView.tsx'
import { WaterfallView } from './WaterfallView.tsx'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
@@ -16,17 +16,25 @@ import { WaterfallView } from './WaterfallView.tsx'
* into an undeclared slot throws — service waiting is what orders this
* apply after the declaring one.
*/
export const inject = ['slots', 'conversation']
export const inject = ['slots', 'conversation', 'sessionHistory']
/**
* 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 {
ctx.slots.register(
{ name: 'conversation.view', id: 'trajectory', order: 10, label: 'Trajectory' }, TrajectoryView)
ctx.slots.register(
{ name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView)
ctx.slots.register({
name: 'conversation.view',
id: 'trajectory',
order: 10,
label: 'Trajectory',
inject: (sessionId: SessionId): TrajectoryViewInjected => {
const history = ctx.sessionHistory.source(sessionId)
return {
hooks: { history },
loadAllHistory: signal => history.loadAll(signal),
}
},
}, TrajectoryView)
}

View File

@@ -3,12 +3,19 @@
* own-duration times, in-flight partial/runningCalls, and group descriptions.
*/
import type {
AssistantBlock,
AssistantMessageNode,
CodeSubCall,
ConversationSnapshot,
RequestInspectionSnapshot,
RequestPromptChange,
RequestView,
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,12 +35,16 @@ export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes']
partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls']
requests?: readonly RequestView[]
callSchemas?: RequestInspectionSnapshot['callSchemas']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches']
}
interface UsageLike {
inputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
outputTokens?: number
reasoningTokens?: number
}
@@ -46,15 +57,85 @@ interface LaidCell {
callId?: string
}
interface LaidGroup {
title: string
laid: LaidCell[]
}
interface TurnBucket {
groups: LaidGroup[]
}
type InputNode = Extract<
ConversationSnapshot['nodes'][number],
{ kind: 'user' | 'steering' | 'context' }
>
type OrderedLayoutEntry =
| {
kind: 'node'
seq: number
node: ConversationSnapshot['nodes'][number]
nodeIndex: number
}
| {
kind: 'compaction'
seq: number
request: RequestView
}
| {
kind: 'system'
seq: number
request: RequestView
change: RequestPromptChange
}
| {
kind: 'request'
seq: number
request: RequestView
}
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
return entry.kind === 'system' && entry.change.kind === 'initial'
? Number.NEGATIVE_INFINITY
: entry.seq
}
function inputCellDetail(node: InputNode): Pick<
TrajectoryCellProps,
'text' | 'sourceSeq' | 'messageSource' | 'inputDetail' | 'sourceBlocks' | 'timeSeconds' | 'startedAt'
> {
return {
text: summarizeContent(node.content),
sourceSeq: node.seq,
messageSource: node.source,
inputDetail: detailContent(node.content),
sourceBlocks: node.content.map(block => sourceBlock(block)),
timeSeconds: 0,
startedAt: finiteTime(node.time),
}
}
/**
* Fold a snapshot into turn → Message/Step groups with expanded cells.
* @param input - nodes plus in-flight partial/runningCalls.
* @returns turns ordered by first appearance.
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const { nodes, partial, runningCalls, codeDispatches } = input
const {
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
} = input
const resultByCall = indexResults(nodes)
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
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, TurnBucket>()
let index = 0
let prevAbsTime: number | null = null
let lastAssistantTurn: number | null = null
@@ -62,26 +143,171 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
const bucket = (turn: number) => {
let entry = turns.get(turn)
if (entry === undefined) {
entry = { message: [], steps: new Map() }
entry = { groups: [] }
turns.set(turn, entry)
}
return entry
}
const pushMessage = (turn: number, laid: LaidCell) => {
bucket(turn).message.push(laid)
const groups = bucket(turn).groups
const last = groups.at(-1)
if (last?.title === 'Message') {
last.laid.push(laid)
return
}
groups.push({ title: 'Message', laid: [laid] })
}
const pushStep = (turn: number, step: number, laid: LaidCell) => {
const steps = bucket(turn).steps
const list = steps.get(step) ?? []
list.push(laid)
steps.set(step, list)
const pushStep = (turn: number, step: number, laid: readonly LaidCell[]) => {
if (laid.length === 0) return
const groups = bucket(turn).groups
const title = `Step ${step}`
const existing = groups.find(group => group.title === title)
if (existing !== undefined) {
existing.laid.push(...laid)
return
}
groups.push({ title, laid: [...laid] })
}
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
if (node === undefined) continue
const representedRequests = new Set<string>()
for (const node of nodes) {
if (node.kind === 'assistant' && node.step > 0) {
representedRequests.add(`${node.turn}\u0000${node.step}`)
}
}
if (partial !== null && partial.step > 0) {
representedRequests.add(`${partial.turn}\u0000${partial.step}`)
}
for (const call of runningCalls) {
if (call.step > 0) representedRequests.add(`${call.turn}\u0000${call.step}`)
}
const entries: OrderedLayoutEntry[] = [
...nodes.map((node, nodeIndex) => ({
kind: 'node' as const,
seq: node.seq,
node,
nodeIndex,
})),
...requests
.filter(request => request.purpose === 'compaction')
.map(request => ({
kind: 'compaction' as const,
seq: request.startSeq,
request,
})),
...requests.flatMap(request => request.promptChange === undefined || request.prompt === undefined
? []
: [{
kind: 'system' as const,
seq: request.promptChange.seq,
request,
change: request.promptChange,
}]),
...requests
.filter(request => request.purpose === 'assistant')
.filter(request =>
!representedRequests.has(`${request.turn}\u0000${request.step}`),
)
.map(request => ({
kind: 'request' as const,
seq: request.startSeq,
request,
})),
].sort((left, right) => layoutEntryOrder(left) - layoutEntryOrder(right))
for (const entry of entries) {
if (entry.kind === 'request') {
const { request } = entry
pushStep(request.turn, request.step, [{
absTime: finiteTime(request.startedAt),
cell: {
index: ++index,
kind: 'message',
text: '',
sourceSeq: request.startSeq,
requestOnly: true,
timeSeconds: request.completedAt === null
? null
: durationSeconds(request.completedAt, request.startedAt),
startedAt: finiteTime(request.startedAt),
...(request.status === 'error' ? { isError: true } : {}),
},
}])
prevAbsTime = finiteTime(request.completedAt)
?? finiteTime(request.startedAt)
?? prevAbsTime
continue
}
if (entry.kind === 'system') {
const { change, request } = entry
const turn = change.kind === 'initial'
? firstVisibleTurn(nodes, partial)
: enclosingPromptTurn(nodes, change.seq, partial)
pushMessage(turn, {
absTime: finiteTime(change.time),
cell: {
index: ++index,
kind: 'system',
text: promptChangeLabel(change),
sourceSeq: change.seq,
...(request.prompt === undefined ? {} : { promptDetail: request.prompt }),
...(change.previous === undefined
? {}
: { previousPromptDetail: change.previous }),
timeSeconds: 0,
startedAt: finiteTime(change.time),
},
})
prevAbsTime = finiteTime(change.time) ?? prevAbsTime
continue
}
if (entry.kind === 'compaction') {
const request = entry.request
const rawOutput = request.rawOutput ?? request.summary
const thinkingDetail = rawOutput === undefined
? ''
: detailReasoning(rawOutput)
const cell: TrajectoryCellProps = {
index: ++index,
kind: 'compacted',
text: request.status === 'running'
? 'Compacting context…'
: request.status === 'error'
? request.error ?? 'Compaction failed'
: request.summary === undefined
? 'Context compacted'
: summarizeContent(request.summary),
sourceSeq: request.startSeq,
...(request.summary === undefined
? {}
: {
outputDetail: detailContent(request.summary),
outputBlocks: request.summary.map(block => sourceBlock(block)),
}),
...(thinkingDetail === '' ? {} : { thinkingDetail }),
...(rawOutput === undefined
? {}
: { sourceBlocks: rawOutput.map(block => sourceBlock(block)) }),
...(request.status === 'error' ? { isError: true } : {}),
timeSeconds: request.completedAt === null
? null
: durationSeconds(request.completedAt, request.startedAt),
startedAt: finiteTime(request.startedAt),
}
attachUsage(cell, request.usage as UsageLike | undefined)
bucket(request.turn).groups.push({
title: `Compaction ${request.startSeq}`,
laid: [{
absTime: finiteTime(request.startedAt),
cell,
}],
})
prevAbsTime = finiteTime(request.completedAt) ?? finiteTime(request.startedAt) ?? prevAbsTime
continue
}
const { node, nodeIndex: i } = entry
if (node.kind === 'user' || node.kind === 'steering') {
// user/message has no turn on the wire; enclose it in the next assistant
// (or partial) turn, else open the turn after the last assistant.
@@ -91,19 +317,22 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
index: ++index, kind: 'user', text: summarizeContent(node.content),
timeSeconds: 0,
index: ++index,
kind: 'user',
...inputCellDetail(node),
opensTurn: node.kind === 'user',
},
})
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'assistant') {
const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches)
for (const laid of laidList) {
if (node.step > 0) pushStep(node.turn, node.step, laid)
else pushMessage(node.turn, laid)
}
const laidList = withSubCalls(
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
codeDispatches,
)
if (node.step > 0) pushStep(node.turn, node.step, laidList)
else for (const laid of laidList) pushMessage(node.turn, laid)
const last = laidList[laidList.length - 1]
if (last !== undefined) index = last.cell.index
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
@@ -111,30 +340,47 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
if (node.kind === 'context') {
// No trajectory cell, but the surface still advances the duration cursor.
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
index: ++index,
kind: 'context',
...inputCellDetail(node),
},
})
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'tool-result') {
if (!callEmittedInAssistant(nodes, node.callId)) {
const toolName = node.call?.name
pushStep(0, 1, {
const laidList: LaidCell[] = [{
absTime: finiteTime(node.callTime ?? node.time),
...(toolName !== undefined ? { toolName } : {}),
callId: node.callId,
cell: {
index: ++index,
kind: 'tool',
sourceSeq: node.seq,
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)) {
pushStep(0, 1, laid)
laidList.push(laid)
index = laid.cell.index
}
pushStep(0, 1, laidList)
}
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
}
@@ -145,11 +391,16 @@ 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 })
for (const laid of laidList) {
if (partial.step > 0) pushStep(partial.turn, partial.step, laid)
else pushMessage(partial.turn, laid)
}
const laidList = expandAssistant(
fake,
index + 1,
prevAbsTime,
resultByCall,
callStartById,
{ streaming: true },
)
if (partial.step > 0) pushStep(partial.turn, partial.step, laidList)
else for (const laid of laidList) pushMessage(partial.turn, laid)
const last = laidList[laidList.length - 1]
if (last !== undefined) index = last.cell.index
}
@@ -157,7 +408,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
const seenCalls = collectCallIds(turns)
for (const call of runningCalls) {
if (seenCalls.has(call.callId)) continue
pushStep(call.turn, call.step > 0 ? call.step : 1, {
const laidList: LaidCell[] = [{
absTime: null,
toolName: call.name,
callId: call.callId,
@@ -165,63 +416,67 @@ 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)) {
pushStep(call.turn, call.step > 0 ? call.step : 1, laid)
laidList.push(laid)
index = laid.cell.index
}
if (call.step > 0) pushStep(call.turn, call.step, laidList)
else for (const laid of laidList) pushMessage(call.turn, laid)
}
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
const prologue = turns.get(0)
if (prologue !== undefined) {
turns.delete(0)
const emptyTurn = (): { message: LaidCell[]; steps: Map<number, LaidCell[]> } => ({
message: [],
steps: new Map(),
})
const emptyTurn = (): TurnBucket => ({ groups: [] })
const first = turns.get(1) ?? emptyTurn()
first.message = [...prologue.message, ...first.message]
for (const [step, cells] of prologue.steps) {
const existing = first.steps.get(step) ?? []
first.steps.set(step, [...cells, ...existing])
}
first.groups = [...prologue.groups, ...first.groups]
turns.set(1, first)
}
for (const entry of turns.values()) {
for (const group of entry.groups) {
for (const laid of group.laid) attachToolSchema(laid, callSchemas)
}
}
return [...turns.entries()]
.sort(([a], [b]) => a - b)
.map(([turn, entry]) => toTurnModel(turn, entry))
}
function attachToolSchema(
laid: LaidCell,
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
): 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[]> },
entry: TurnBucket,
): TrajectoryTurnModel {
const groups: TrajectoryGroupModel[] = []
if (entry.message.length > 0) {
const description = groupDescription(entry.message)
groups.push({
title: 'Message',
...(description !== undefined ? { description } : {}),
cells: entry.message.map(l => l.cell),
})
}
for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) {
const laid = entry.steps.get(step) ?? []
const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => {
const description = groupDescription(laid)
groups.push({
title: `Step ${step}`,
return {
title,
...(description !== undefined ? { description } : {}),
cells: laid.map(l => l.cell),
})
}
}
})
return { turn, groups }
}
/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */
/** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */
function groupDescription(laid: readonly LaidCell[]): string | undefined {
const parts: string[] = []
// Tool rows contribute start (absTime) and end (start + own duration) so a
@@ -256,8 +511,8 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined {
function formatGroupDuration(seconds: number): string | undefined {
if (!Number.isFinite(seconds)) return undefined
const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `${rounded}s`
return `${rounded.toFixed(1)}s`
if (Number.isInteger(rounded)) return `${rounded} s`
return `${rounded.toFixed(1)} s`
}
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
@@ -267,8 +522,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,66 +531,175 @@ function expandAssistant(
startIndex: number,
prevAbsTime: number | null,
results: Map<string, ToolResultNode>,
callStarts: ReadonlyMap<string, number>,
opts?: { streaming?: boolean },
): LaidCell[] {
const out: LaidCell[] = []
let index = startIndex - 1
const usage = node.usage as UsageLike | undefined
const streaming = opts?.streaming === true
const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime)
const recordedStart = finiteTime(node.timing?.stepStartTime)
const messageDuration = streaming
? null
: durationSeconds(node.time, recordedStart ?? 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',
sourceSeq: node.seq,
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: recordedStart,
}
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 promptChangeLabel(change: RequestPromptChange): string {
if (change.kind === 'initial') return 'Initial System Prompt'
if (change.kind === 'system') return 'System Prompt Updated'
if (change.kind === 'tools') return 'Tools Updated'
return 'System Prompt and Tools Updated'
}
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).
@@ -357,10 +721,37 @@ function enclosingUserTurn(
return 1
}
function enclosingPromptTurn(
nodes: ConversationSnapshot['nodes'],
seq: number,
partial: ConversationSnapshot['partial'],
): number {
const next = nodes.find(node =>
node.seq > seq && node.kind === 'assistant' && node.step > 0)
if (next?.kind === 'assistant') return next.turn
return partial?.turn ?? 1
}
/** Earliest raw turn represented by the selected trajectory branch. */
function firstVisibleTurn(
nodes: ConversationSnapshot['nodes'],
partial: ConversationSnapshot['partial'],
): number {
const turns = nodes.flatMap(node =>
(node.kind === 'assistant' || node.kind === 'steering') && node.turn > 0
? [node.turn]
: [],
)
if (partial !== null && partial.turn > 0) turns.push(partial.turn)
return turns.length === 0 ? 1 : Math.min(...turns)
}
/** Copy provider usage onto a Message cell when present. */
function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
if (usage === undefined) return
if (usage.inputTokens !== undefined) cell.input = usage.inputTokens
if (usage.cacheReadTokens !== undefined) cell.cacheRead = usage.cacheReadTokens
if (usage.cacheWriteTokens !== undefined) cell.cacheWrite = usage.cacheWriteTokens
if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
}
@@ -382,15 +773,12 @@ function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: st
}
function collectCallIds(
turns: Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>,
turns: Map<number, TurnBucket>,
): Set<string> {
const ids = new Set<string>()
for (const entry of turns.values()) {
for (const laid of entry.message) {
if (laid.callId !== undefined) ids.add(laid.callId)
}
for (const list of entry.steps.values()) {
for (const laid of list) {
for (const group of entry.groups) {
for (const laid of group.laid) {
if (laid.callId !== undefined) ids.add(laid.callId)
}
}
@@ -433,12 +821,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 +851,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 {
@@ -461,7 +863,40 @@ function summarizeResult(node: ToolResultNode): string {
return summarizeText(block.text)
}
}
return node.call?.name ?? node.callId
return 'No output'
}
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
if (
node.content.length === 0
|| node.content.every(block =>
block.type === 'text' && (typeof block.text !== 'string' || block.text === ''))
) return 'No output'
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 detailReasoning(content: readonly { type: string; text?: string }[]): string {
return content
.filter(block => block.type === 'reasoning' && typeof block.text === 'string')
.map(block => block.text ?? '')
.join('\n')
}
function summarizeContent(content: readonly { type: string; text?: string }[]): string {

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,186 @@
/** Operation-sequence and recorded-time projections for the trajectory overview. */
import type { TrajectoryTurnModel } from './layout.ts'
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
/** Horizontal projection used by the trajectory timeline. */
export type TrajectoryTimelineMode = 'sequence' | 'duration' | 'time' | 'actual'
/** Inclusive selection in the active timeline projection's domain. */
export interface TrajectoryTimeRange {
start: number
end: number
}
/** One ledger record projected into the active timeline domain. */
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
index: number
kind: TrajectoryCellKind
label: string
lane: number
}
/** One turn boundary in the active timeline domain. */
export interface TrajectoryTimelineTurnBoundary {
turn: number
time: number
}
/** Full-domain model used by the overview. */
export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
spans: readonly TrajectoryTimelineSpan[]
turnBoundaries: readonly TrajectoryTimelineTurnBoundary[]
}
/**
* Format a timeline duration with a compact unit.
* @param milliseconds - Non-negative duration in milliseconds.
* @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`
}
function laneFor(kind: TrajectoryCellKind): number {
if (kind === 'tool' || kind === 'subtool') return 2
if (kind === 'message' || kind === 'compacted') return 1
return 0
}
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 }
}
/**
* Project every visible record into a stable three-lane timeline.
* @param turns - Unfiltered trajectory layout.
* @param mode - Independent equal/recorded duration and compressed/complete time projection.
* @returns Timeline model, or `null` when no record is visible.
*/
export function deriveTrajectoryTimeline(
turns: readonly TrajectoryTurnModel[],
mode: TrajectoryTimelineMode = 'sequence',
): TrajectoryTimelineModel | null {
if (mode !== 'sequence') {
return deriveTimedTimeline(
turns,
mode === 'duration' || mode === 'actual',
mode === 'duration',
)
}
const spans: TrajectoryTimelineSpan[] = []
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
for (const turn of turns) {
const cells = turn.groups.flatMap(group =>
group.cells.filter(cell => cell.requestOnly !== true),
)
if (cells.length === 0) continue
turnBoundaries.push({
turn: turn.turn,
time: spans.length,
})
spans.push(...cells.map((cell, offset): TrajectoryTimelineSpan => ({
start: spans.length + offset,
end: spans.length + offset + 1,
index: cell.index,
kind: cell.kind,
label: cell.text,
lane: laneFor(cell.kind),
})))
}
if (spans.length === 0) return null
return {
start: 0,
end: spans.length,
spans,
turnBoundaries,
}
}
function deriveTimedTimeline(
turns: readonly TrajectoryTurnModel[],
actualDuration: boolean,
removeUserIdle: boolean,
): TrajectoryTimelineModel | null {
const spans: TrajectoryTimelineSpan[] = []
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
let removedUserIdle = 0
let previousTurnEnd: number | null = null
for (const turn of turns) {
const rawSpans = 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 (rawSpans.length === 0) continue
const turnStart = Math.min(...rawSpans.map(span => span.start))
const turnEnd = Math.max(...rawSpans.map(span => span.end))
if (removeUserIdle && previousTurnEnd !== null) {
removedUserIdle += Math.max(0, turnStart - previousTurnEnd)
}
spans.push(...rawSpans.map(span => ({
...span,
start: span.start - removedUserIdle,
end: (actualDuration ? span.end : span.start) - removedUserIdle,
})))
turnBoundaries.push({
turn: turn.turn,
time: turnStart - removedUserIdle,
})
previousTurnEnd = previousTurnEnd === null
? turnEnd
: Math.max(previousTurnEnd, turnEnd)
}
if (spans.length === 0) return null
return {
start: Math.min(...spans.map(span => span.start)),
end: Math.max(...spans.map(span => span.end)),
spans,
turnBoundaries,
}
}
/**
* Identify records active at any point inside an inclusive selected interval.
* @param turns - Unfiltered trajectory layout.
* @param range - Selected interval in the active projection.
* @param mode - Independent equal/recorded duration and compressed/complete time projection.
* @returns Record indexes inside the focus interval.
*/
export function trajectoryTimelineFocusIndexes(
turns: readonly TrajectoryTurnModel[],
range: TrajectoryTimeRange,
mode: TrajectoryTimelineMode = 'sequence',
): ReadonlySet<number> {
const model = deriveTrajectoryTimeline(turns, mode)
return new Set(
model?.spans
.filter(span => span.start <= range.end && span.end >= range.start)
.map(span => span.index),
)
}

View File

@@ -0,0 +1,104 @@
/** Shared trajectory record data and formatting contracts. */
import type { HTMLAttributes } from 'react'
import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
/** Closed set of trajectory record kinds. */
export type TrajectoryCellKind =
| 'system'
| 'user'
| 'context'
| 'compacted'
| '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
/** Source session-event seq for cross-record navigation. */
sourceSeq?: number
/** Producer provenance from a user-role message or context injection. */
messageSource?: unknown
/** Producer-owned model-hidden metadata carried beside the message source. */
/** A separator-only anchor for an auxiliary request with no visible record. */
requestOnly?: boolean
/** Full request/message content for the details panel. */
inputDetail?: string
/** Complete system-prompt/tool-catalog state introduced by a SYSTEM record. */
promptDetail?: ConversationPromptSnapshot
/** System-prompt/tool-catalog state replaced by a SYSTEM update. */
previousPromptDetail?: ConversationPromptSnapshot
/** 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 input tokens served from a provider cache. */
cacheRead?: number
/** Message-only input tokens written into a provider cache. */
cacheWrite?: 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 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`
}

View File

@@ -1,89 +1,29 @@
/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge;
* cell content width is capped on the turn body (max 880). Under the
* active conversation column (`[data-conversation-scroll]`) the parent
* owns overflow so the sticky composer stays in the same scrollport. */
/* Full-bleed, fixed-height host for the trajectory ledger. */
.root {
overflow-y: auto;
--dsh-trajectory-toolbar-height: 32px;
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);
}
/* Under the active conversation column (`[data-conversation-scroll]`) the
* parent owns overflow so the sticky composer stays in the same scrollport. */
:global([data-conversation-scroll]) .root {
overflow: visible;
height: auto;
}
.empty {
padding: 16px;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
/* Waterfall placeholder rows (shared module). */
.row {
.ledger {
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;
flex: 1;
min-height: 0;
min-width: 0;
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

@@ -16,11 +16,11 @@ afterEach(cleanup)
describe('formatElapsedSeconds', () => {
it('formats known durations and uses an em dash when absent', () => {
expect(formatElapsedSeconds(null)).toBe('—')
expect(formatElapsedSeconds(235)).toBe('+235s')
expect(formatElapsedSeconds(235.0)).toBe('+235s')
expect(formatElapsedSeconds(235.2)).toBe('+235.2s')
expect(formatElapsedSeconds(235.25)).toBe('+235.3s')
expect(formatElapsedSeconds(0)).toBe('+0s')
expect(formatElapsedSeconds(235)).toBe('235 s')
expect(formatElapsedSeconds(235.0)).toBe('235 s')
expect(formatElapsedSeconds(235.2)).toBe('235.2 s')
expect(formatElapsedSeconds(235.25)).toBe('235.3 s')
expect(formatElapsedSeconds(0)).toBe('0 s')
expect(formatElapsedSeconds(Number.NaN)).toBe('—')
})
})
@@ -38,7 +38,7 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('#6')).toBeTruthy()
expect(screen.getByText('Tool')).toBeTruthy()
expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy()
expect(screen.getByText('+5s')).toBeTruthy()
expect(screen.getByText('5 s')).toBeTruthy()
})
it('Message rows expose Input / Output / Think metric columns before time', () => {
@@ -57,11 +57,11 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('136')).toBeTruthy()
expect(screen.getByText('381')).toBeTruthy()
expect(screen.getByText('155')).toBeTruthy()
expect(screen.getByText('+235.2s')).toBeTruthy()
expect(screen.getByText('235.2 s')).toBeTruthy()
const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('235.2 s'))
})
it('selected marks the row for the brand-primary inset ring', () => {

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'
@@ -47,6 +47,7 @@ describe('tsdown client artifact', () => {
const modules = new Map<string, unknown>([
['react', await import('react')],
['react/jsx-runtime', await import('react/jsx-runtime')],
['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
])
const surface = handoff!.factory((spec) => {
if (!modules.has(spec)) throw new Error(`unexpected require: ${spec}`)
@@ -59,10 +60,10 @@ describe('tsdown client artifact', () => {
const { handoff, surface } = await loadArtifact()
expect(handoff.id).toBe(PLUGIN_ID)
expect(surface.apply).toBeTypeOf('function')
expect(surface.inject).toEqual(['slots', 'conversation'])
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)
@@ -71,13 +72,13 @@ describe('tsdown client artifact', () => {
name: 'root',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
}, (_p: { renderSlot?: unknown }) => null)
// The plugin injects 'conversation' as an ordering edge (the declaring
// plugin provides it after declaring the ring); the bench declares the
// ring itself, so a stub satisfies the wait.
// The plugin injects 'conversation' as an ordering edge and 'sessions'
// for its per-session history callback; this bench supplies both.
ctx.provide('conversation', {})
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

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import type {
ConversationContext, ConversationNode, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches,
trajectoryBranchContainsRequest,
} from '../src/client/context-branches.ts'
const checkpoint = {
kind: 'context',
seq: 100,
time: 100,
content: [],
source: { kind: 'plugin', plugin: 'compact' },
} as ConversationNode
const abandoned = {
kind: 'assistant',
seq: 20,
time: 20,
turn: 1,
step: 1,
blocks: [{ kind: 'text', text: 'abandoned' }],
} as ConversationNode
const current = {
kind: 'user',
seq: 110,
time: 110,
content: [{ type: 'text', text: 'rewound' }],
source: { kind: 'plugin', plugin: 'rewind' },
} as ConversationNode
function request(
purpose: RequestView['purpose'],
startSeq: number,
resultSeq?: number,
replacementSeq?: number,
): RequestView {
return {
purpose,
startSeq,
turn: 1,
step: purpose === 'assistant' ? 1 : 0,
startedAt: startSeq,
completedAt: startSeq + 1,
status: 'complete',
...(resultSeq === undefined ? {} : { resultSeq }),
...(replacementSeq === undefined ? {} : { replacementSeq }),
}
}
describe('trajectory context branches', () => {
it('inherits nodes and requests by retained surface position rather than seq cutoff', () => {
const contexts: ConversationContext[] = [
{ id: 0, nodes: [checkpoint, abandoned] },
{
id: 1,
parentId: 0,
origin: 'rewind',
originSeq: 110,
nodes: [checkpoint, current],
},
]
const branches = deriveTrajectoryContextBranches(contexts)
const successor = branches[1]!
expect(successor.nodes.map(node => node.seq)).toEqual([110])
expect(trajectoryBranchContainsRequest(
successor,
request('assistant', 10, 20),
)).toBe(false)
expect(trajectoryBranchContainsRequest(
successor,
request('compaction', 90, 95, 100),
)).toBe(true)
expect(trajectoryBranchContainsRequest(
successor,
request('assistant', 111),
)).toBe(true)
})
})

View File

@@ -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('3 s 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,
})
})
@@ -204,6 +204,23 @@ describe('deriveTrajectoryLayout', () => {
// From context at 9s, not from the earlier user/tool surfaces.
expect(message?.timeSeconds).toBe(1)
})
it('uses the recorded step start for assistant duration when timing exists', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
{
kind: 'assistant', seq: 2, time: 4_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'done' }],
timing: { stepStartTime: 3_000, firstTokenTime: 3_500, completedTime: 4_000 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
})
const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message')
expect(message).toMatchObject({ startedAt: 3_000, timeSeconds: 1 })
})
})
describe('run_code sub-dispatch cells', () => {
@@ -235,11 +252,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('Tool call only')
// 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', () => {

View File

@@ -0,0 +1,190 @@
// @vitest-environment jsdom
/** Trajectory ledger selection, details, status, and fold behavior. */
import { afterEach, describe, expect, it, vi } 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,
},
],
}],
}]
const FOLD_PROPS = {
collapsedTurns: new Set<number>(),
onToggleTurn: () => {},
collapsedAssistants: new Set<number>(),
onToggleAssistant: () => {},
}
describe('TrajectoryTable', () => {
it('shows assistant timing facts after keyboard selection', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
fireEvent.click(screen.getByRole('button', { name: 'Timing' }))
expect(screen.getByText('500 ms')).toBeTruthy()
expect(screen.getByText('1.00 s')).toBeTruthy()
expect(screen.getByText('20.0 tok/s')).toBeTruthy()
})
it('breaks output tokens into labeled reasoning and content rows', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
expect(screen.getByText('Tokens')).toBeTruthy()
expect(screen.getByText('20 tok')).toBeTruthy()
expect(screen.getByText('Reasoning')).toBeTruthy()
expect(screen.getByText('5 tok')).toBeTruthy()
expect(screen.getByText('Content')).toBeTruthy()
expect(screen.getByText('15 tok')).toBeTruthy()
})
it('keeps raw HTML tags in a Markdown-derived context preview', () => {
const html = [
'<background-task-complete id="trajectory-ui-watch">',
'Command: pnpm test',
'Exit code: 0',
'</background-task-complete>',
].join('\n')
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Message',
cells: [{
index: 1,
kind: 'context',
text: '',
inputDetail: html,
timeSeconds: 0,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
expect(screen.getByText(
'<background-task-complete id="trajectory-ui-watch"> Command: pnpm test Exit code: 0 </background-task-complete>',
)).toBeTruthy()
})
it('clears the selected row when ledger whitespace is clicked', () => {
const onClearSelection = vi.fn()
render(
<TrajectoryTable
turns={TURNS}
{...FOLD_PROPS}
onClearSelection={onClearSelection}
/>,
)
const row = screen.getByRole('row', { name: /ASSISTANT/ })
fireEvent.click(row)
expect(row.getAttribute('aria-selected')).toBe('true')
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
const tablePane = screen.getByRole('table').parentElement
expect(tablePane).not.toBeNull()
fireEvent.click(tablePane as HTMLElement)
expect(row.getAttribute('aria-selected')).toBe('false')
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
expect(onClearSelection).toHaveBeenCalledOnce()
})
it('keeps running and failure semantics distinct from record roles', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
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: /TOOL, bash \{"command":"pwd"\}/ }))
expect(screen.getByText('Pending')).toBeTruthy()
fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ }))
expect(screen.getByText('Failed')).toBeTruthy()
fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy()
})
it('renders a single-text JSON tool result as a JSON tree', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'tool',
text: 'read {"path":"result.json"}',
outputDetail: '{"value":1,"nested":{"ok":true}}',
outputBlocks: [{
type: 'text',
content: '{"value":1,"nested":{"ok":true}}',
}],
timeSeconds: 0.1,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
expect(screen.getByRole('tree', { name: 'Result JSON' })).toBeTruthy()
expect(screen.getByText('value:')).toBeTruthy()
})
it('keeps the first row and a compact summary when a turn is collapsed', () => {
render(
<TrajectoryTable
turns={TURNS}
{...FOLD_PROPS}
collapsedTurns={new Set([1])}
/>,
)
expect(screen.queryByRole('columnheader')).toBeNull()
expect(screen.getByRole('row', { name: /ASSISTANT/ })).toBeTruthy()
expect(screen.getByRole('row', { name: /Collapsed turn summary/ })).toBeTruthy()
})
})

View File

@@ -1,31 +1,34 @@
// @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
* turn-list 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 focus edge cases ride along.
*/
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement, type FC, type ReactNode } from 'react'
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
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 { 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 { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
import {
TrajectoryView, type TrajectoryViewInjected,
} from '../src/client/TrajectoryView.tsx'
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
const SID = 's1' as SessionId
afterEach(cleanup)
@@ -40,17 +43,56 @@ 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 historySnapshot(
nodes: ConversationSnapshot['nodes'],
inspection: Partial<SessionHistoryInspection> = {},
): SessionHistorySnapshot {
return {
state: 'ready',
error: null,
hasMore: false,
inspection: {
eventNodes: nodes,
contexts: [{ id: 0, nodes }],
requests: [],
callSchemas: new Map(),
interruptedNodes: [],
partial: null,
runningCalls: [],
codeDispatches: new Map(),
...inspection,
},
}
}
function standaloneHistory(
snapshot: SessionHistorySnapshot,
): Pick<ComponentProps<typeof TrajectoryView>, 'useHistory' | 'loadAllHistory'> {
const store = createSnapshotStore(snapshot)
return {
useHistory: bindSnapshotSelector(store),
loadAllHistory: () => Promise.resolve(),
}
}
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
nodes, pending: [], partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
@@ -82,9 +124,17 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
}
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
async function bench() {
async function bench(snapshot = historySnapshot(NODES)) {
const ctx = new Context()
const slots = new SlotsService(ctx)
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
const historyStore = createSnapshotStore(snapshot)
const history: SessionHistoryFace = {
sessionId: SID,
getSnapshot: () => historyStore.getSnapshot(),
subscribe: listener => historyStore.subscribe(listener),
loadAll: loadAllHistory,
}
// The conversation entry's role: declare the ring, then seed the chat entry.
slots.register({
name: 'root',
@@ -96,9 +146,10 @@ async function bench() {
// 'conversation' inject is an ordering edge; the bench declares the ring
// itself, so a stub satisfies the wait.
ctx.provide('conversation', {})
ctx.provide('sessionHistory', { source: () => history })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber }
return { ctx, slots, fiber, loadAllHistory }
}
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
@@ -111,6 +162,8 @@ function tabsOf(slots: SlotsService): ViewTab[] {
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
const sessionSnapshot = createSnapshotStore({
running: false, removed: false, promptError: null, nodes,
pending: [],
openState: 'open' as const, hasMore: true, loadingOlder: false,
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
@@ -122,8 +175,21 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
if (entry === undefined) return null
const View = entry.component as FC<ConvViewProps>
const injectEntry = entry.inject as ((sessionId: SessionId) => object) | undefined
const injected = injectEntry === undefined
? {}
: injectEntry(SID)
const injectedProps = 'hooks' in injected
? {
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
useHistory: bindSnapshotSelector(
(injected as TrajectoryViewInjected).hooks.history,
),
}
: injected
return (
<View
{...injectedProps}
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
key={key}
/>
@@ -154,16 +220,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'])
@@ -171,80 +236,275 @@ 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 two 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'])
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory'])
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.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' }))
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
expect(screen.queryByTestId('chat-body')).toBeNull()
await vi.waitFor(() => {
expect(b.loadAllHistory).toHaveBeenCalledOnce()
})
const signal = b.loadAllHistory.mock.calls[0]?.[0]
expect(signal?.aborted).toBe(false)
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
expect(signal?.aborted).toBe(true)
})
it('waterfall renders bars and switching back to chat does not collapse it', async () => {
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: 'Waterfall' }))
expect(screen.getByTitle('2 nodes')).toBeTruthy()
expect(screen.getByTitle('1 tool calls')).toBeTruthy()
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
expect(screen.getByTestId('chat-body')).toBeTruthy()
fireEvent.keyDown(screen.getByRole('row', { name: /TOOL/ }), { key: 'Enter' })
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy()
expect(screen.getByText('Completed')).toBeTruthy()
expect(screen.getByRole('tab', { name: 'Result' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Close details' }))
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
})
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
const b = await bench()
mount(b.slots, [])
mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus 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 })
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBe('outside')
const tablePane = screen.getByRole('table').parentElement
expect(tablePane).not.toBeNull()
fireEvent.click(tablePane as HTMLElement)
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBeNull()
fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 2 })
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 2 })
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 2 })
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBe('outside')
fireEvent.contextMenu(plot)
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBeNull()
})
it('empty window keeps the toolbar and reports no timing data', async () => {
const b = await bench(historySnapshot([]))
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 equal-width operation slots and stable semantic lanes', () => {
expect(deriveTrajectoryTimeline(turns)).toEqual({
start: 0,
end: 3,
spans: [
{
index: 1, kind: 'message', label: 'assistant', lane: 1, start: 0, end: 1,
},
{ index: 2, kind: 'tool', label: 'bash', lane: 2, start: 1, end: 2 },
{ index: 3, kind: 'user', label: 'unknown', lane: 0, start: 2, end: 3 },
],
turnBoundaries: [{ turn: 1, time: 0 }],
})
})
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()
render(createElement(TrajectoryView as FC<ConvViewProps>,
standaloneProps([])))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
it('ignores durations and idle gaps while retaining turn boundaries', () => {
const separatedTurns = [
{
turn: 1,
groups: [{
title: 'Step 1',
cells: [
{ index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
{ index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
],
}],
},
{
turn: 2,
groups: [{
title: 'Step 1',
cells: [
{ index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
],
}],
},
] satisfies readonly TrajectoryTurnModel[]
expect(deriveTrajectoryTimeline(separatedTurns)).toMatchObject({
start: 0,
end: 3,
spans: [
{ index: 1, start: 0, end: 1 },
{ index: 2, start: 1, end: 2 },
{ index: 3, start: 2, end: 3 },
],
turnBoundaries: [
{ turn: 1, time: 0 },
{ turn: 2, time: 2 },
],
})
})
it('empty inputs produce no model and the standalone view reports its empty form', () => {
expect(deriveTrajectoryTimeline([])).toBeNull()
render(createElement(
TrajectoryView,
{
...standaloneProps([]),
...standaloneHistory(historySnapshot([])),
},
))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.queryByRole('row')).toBeNull()
})
})
describe('WaterfallView standalone branches', () => {
it('empty window renders the placeholder copy', () => {
render(createElement(WaterfallView as FC<ConvViewProps>,
standaloneProps([])))
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
describe('TrajectoryView branches', () => {
it('renders only the selected rewind branch while retaining session-global requests', () => {
const retained = {
kind: 'user',
seq: 1,
time: 1_000,
content: [{ type: 'text', text: 'retained user' }],
source: null,
} as unknown as ConversationSnapshot['nodes'][number]
const abandoned = {
kind: 'assistant',
seq: 3,
time: 3_000,
turn: 1,
step: 1,
blocks: [{ kind: 'text', text: 'abandoned response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const current = {
kind: 'assistant',
seq: 5,
time: 5_000,
turn: 2,
step: 1,
blocks: [{ kind: 'text', text: 'current response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const request = (startSeq: number, turn: number): RequestView => ({
purpose: 'assistant',
startSeq,
turn,
step: 1,
startedAt: startSeq * 1_000,
completedAt: startSeq * 1_000 + 100,
status: 'complete',
})
const store = createSnapshotStore(historySnapshot(
[retained, abandoned, current],
{
eventNodes: [retained, abandoned, current],
contexts: [
{ id: 0, nodes: [retained, abandoned] },
{
id: 1,
parentId: 0,
origin: 'rewind' as const,
originSeq: 4,
nodes: [retained, current],
},
],
requests: [request(2, 1), request(4, 2)],
callSchemas: new Map(),
},
))
const view = render(
<TrajectoryView
{...standaloneProps([])}
useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())}
/>,
)
expect(screen.queryByText('abandoned response')).toBeNull()
expect(screen.getByText('current response')).toBeTruthy()
expect(screen.getByRole('row', { name: /Request 2, ASSISTANT/ })).toBeTruthy()
expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0)
})
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()
it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => {
const retained = {
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'stop the task' }], source: null,
} as unknown as ConversationSnapshot['nodes'][number]
const interruptedAssistant = {
kind: 'assistant', seq: 2.1, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'partial response retained' }],
interrupted: true,
} as unknown as ConversationSnapshot['nodes'][number]
const interruptedTool = {
kind: 'tool-result', seq: 2.2, time: 2_100, callId: 'slow-call',
call: { name: 'bash', argsRaw: '{"command":"sleep 30"}' }, callTime: 1_900,
content: [], isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
callView: null, resultView: null,
} as unknown as ConversationSnapshot['nodes'][number]
const store = createSnapshotStore(historySnapshot(
[retained],
{
eventNodes: [retained],
contexts: [{ id: 0, nodes: [retained] }],
requests: [],
callSchemas: new Map(),
interruptedNodes: [interruptedAssistant, interruptedTool],
},
))
render(
<TrajectoryView
{...standaloneProps([])}
useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())}
/>,
)
expect(screen.getByText('partial response retained')).toBeTruthy()
expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy()
})
})
@@ -253,117 +513,3 @@ describe('node half', () => {
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(),
useProjection: (() => undefined) as never,
} 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.80s"]')).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(),
useProjection: (() => undefined) as never,
} 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')
})
})