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

@@ -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 = () => {}