feat(client): add trajectory timing overview

This commit is contained in:
_Kerman
2026-07-28 16:28:57 +08:00
parent 40c29495af
commit 1d4a6149cc
39 changed files with 716 additions and 592 deletions
@@ -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);
}
@@ -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>
})
@@ -0,0 +1,181 @@
.root {
flex: none;
padding: 8px 16px 12px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
user-select: none;
}
.header {
display: flex;
align-items: center;
min-height: 24px;
gap: 8px;
}
.title {
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xs-13);
font-weight: 600;
}
.summary {
flex: 1;
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
}
.clear {
flex: none;
padding: 2px 8px;
border: 0;
border-radius: 4px;
color: var(--dsw-alias-state-business-primary);
background: transparent;
font: var(--dsw-font-xs-13);
cursor: pointer;
}
.clear:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.clear:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
.plot {
position: relative;
height: 72px;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
background: var(--dsw-alias-bg-layer-2);
cursor: crosshair;
touch-action: none;
}
.plot:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
.ticks {
position: absolute;
inset: 0 8px auto;
height: 20px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.tick {
position: absolute;
left: var(--trajectory-tick-left);
padding: 2px 4px;
transform: translateX(-50%);
white-space: nowrap;
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
}
.tick:first-child {
transform: none;
}
.tick:last-child {
transform: translateX(-100%);
}
.tick::after {
position: absolute;
top: 20px;
bottom: -52px;
left: 50%;
width: 1px;
background: var(--dsw-alias-border-l1);
content: '';
}
.lanes {
position: absolute;
inset: 24px 8px 6px;
}
.span {
position: absolute;
top: calc(var(--trajectory-span-lane) * 14px);
left: var(--trajectory-span-left);
width: var(--trajectory-span-width);
height: 8px;
min-width: 2px;
border-radius: 1px;
background: var(--dsw-alias-label-tertiary);
opacity: 0.72;
}
.span[data-timeline-span='message'],
.span[data-timeline-span='compacted'] {
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.span[data-timeline-span='tool'],
.span[data-timeline-span='subtool'] {
background: var(--dsw-alias-state-business-primary);
}
.selection {
position: absolute;
top: 0;
bottom: 0;
left: var(--trajectory-selection-left);
width: var(--trajectory-selection-width);
min-width: 1px;
border-right: 1px solid var(--dsw-alias-state-business-primary);
border-left: 1px solid var(--dsw-alias-state-business-primary);
background: color-mix(
in srgb,
var(--dsw-alias-state-business-primary) 12%,
transparent
);
box-shadow:
-100vw 0 0 100vw color-mix(in srgb, var(--dsw-alias-bg-layer-1) 58%, transparent),
100vw 0 0 100vw color-mix(in srgb, var(--dsw-alias-bg-layer-1) 58%, transparent);
pointer-events: none;
}
.selection::before,
.selection::after {
position: absolute;
top: 0;
bottom: 0;
width: 3px;
background: var(--dsw-alias-state-business-primary);
content: '';
}
.selection::before {
left: -2px;
}
.selection::after {
right: -2px;
}
.selection[data-dragging='true'] {
background: color-mix(
in srgb,
var(--dsw-alias-state-business-primary) 18%,
transparent
);
}
@media (max-width: 720px) {
.root {
padding-right: 12px;
padding-left: 12px;
}
.plot {
height: 64px;
}
}
@@ -0,0 +1,216 @@
/** Chrome-Network-style overview timeline for focusing the trajectory ledger. */
import {
memo, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent,
} from 'react'
import type { TrajectoryTurnModel } from './layout.ts'
import {
deriveTrajectoryTimeline,
filterTrajectoryTimelineRange,
formatTimelineOffset,
type TrajectoryTimeRange,
} from './timeline.ts'
import css from './TrajectoryTimeline.module.css'
const TICK_COUNT = 5
const MINIMUM_DRAG_PX = 3
interface FractionRange {
start: number
end: number
}
/** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
range: TrajectoryTimeRange | null
onRangeChange: (range: TrajectoryTimeRange | null) => void
}
function orderedRange(left: number, right: number): FractionRange {
return left <= right ? { start: left, end: right } : { start: right, end: left }
}
function clampFraction(value: number): number {
return Math.min(1, Math.max(0, value))
}
function rangeFraction(
range: TrajectoryTimeRange,
start: number,
duration: number,
): FractionRange {
return orderedRange(
clampFraction((range.start - start) / duration),
clampFraction((range.end - start) / duration),
)
}
/** Overview renderer with drag-to-filter and Escape/clear reset. */
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
turns,
range,
onRangeChange,
}: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns), [turns])
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
const [draft, setDraft] = useState<FractionRange | null>(null)
const domainDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
const committed = model === null || range === null
? null
: rangeFraction(range, model.start, domainDuration)
const visibleRange = draft ?? committed
const focusedCount = useMemo(
() => range === null
? model?.spans.length ?? 0
: deriveTrajectoryTimeline(filterTrajectoryTimelineRange(turns, range))?.spans.length ?? 0,
[model?.spans.length, range, turns],
)
if (model === null) {
return (
<section className={css.root} aria-label="Trajectory timeline">
<div className={css.header}>
<span className={css.title}>Overview</span>
<span className={css.summary}>No timing data</span>
</div>
</section>
)
}
const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
const rect = event.currentTarget.getBoundingClientRect()
return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
}
const commit = (fraction: FractionRange) => {
onRangeChange({
start: model.start + fraction.start * domainDuration,
end: model.start + fraction.end * domainDuration,
})
}
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
const rect = event.currentTarget.getBoundingClientRect()
const anchor = fractionAt(event)
dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) }
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId)
}
setDraft({ start: anchor, end: anchor })
}
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
setDraft(orderedRange(drag.anchor, fractionAt(event)))
}
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
const selected = orderedRange(drag.anchor, fractionAt(event))
dragRef.current = null
setDraft(null)
if ((selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX) {
onRangeChange(null)
} else {
commit(selected)
}
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Escape' || range === null) return
event.preventDefault()
onRangeChange(null)
}
const onPointerCancel = () => {
dragRef.current = null
setDraft(null)
}
const ticks = Array.from({ length: TICK_COUNT }, (_, index) => {
const fraction = index / (TICK_COUNT - 1)
return {
fraction,
label: formatTimelineOffset(fraction * domainDuration),
}
})
const summary = range === null
? `${model.spans.length} timed events`
: `${focusedCount} of ${model.spans.length} events · ${formatTimelineOffset(range.start - model.start)}${formatTimelineOffset(range.end - model.start)}`
return (
<section className={css.root} aria-label="Trajectory timeline">
<div className={css.header}>
<span className={css.title}>Overview</span>
<span className={css.summary} aria-live="polite">{summary}</span>
{range !== null && (
<button
className={css.clear}
type="button"
onClick={() => {
onRangeChange(null)
}}
>
Clear selection
</button>
)}
</div>
<div
className={css.plot}
aria-label="Timeline overview; drag horizontally to filter events"
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerCancel}
>
<div className={css.ticks} aria-hidden="true">
{ticks.map(tick => (
<span
className={css.tick}
key={tick.fraction}
style={{ '--trajectory-tick-left': `${tick.fraction * 100}%` } as CSSProperties}
>
{tick.label}
</span>
))}
</div>
<div className={css.lanes} aria-hidden="true">
{model.spans.map((span) => {
const left = (span.start - model.start) / domainDuration
const width = (span.end - span.start) / domainDuration
return (
<span
className={css.span}
data-timeline-span={span.kind}
key={span.index}
title={`${span.label} · ${formatTimelineOffset(span.end - span.start)}`}
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
'--trajectory-span-lane': span.lane,
} as CSSProperties}
/>
)
})}
</div>
{visibleRange !== null && (
<div
className={css.selection}
data-dragging={draft === null ? undefined : 'true'}
aria-hidden="true"
style={{
'--trajectory-selection-left': `${visibleRange.start * 100}%`,
'--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
} as CSSProperties}
/>
)}
</div>
</section>
)
})
@@ -14,7 +14,11 @@ import {
type TrajectoryUsage,
} from './TrajectoryTable.tsx'
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
import { deriveTrajectoryLayout } from './layout.ts'
import {
filterTrajectoryTimelineRange, type TrajectoryTimeRange,
} from './timeline.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
@@ -73,6 +77,10 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<number>>(EMPTY_IDS)
const [timelineSelection, setTimelineSelection] = useState<{
branchId: number
range: TrajectoryTimeRange
} | null>(null)
const nodes = useSession(s => s.nodes)
const inspection = useSession(s => s.inspection)
const hasMore = useSession(s => s.hasMore)
@@ -249,8 +257,15 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
],
)
const timelineRange = timelineSelection?.branchId === currentBranch.id
? timelineSelection.range
: null
const focusedTurns = useMemo(
() => filterTrajectoryTimelineRange(turns, timelineRange),
[timelineRange, turns],
)
const collapsibleTurnIds = useMemo(
() => turns
() => focusedTurns
.filter(turn =>
turn.groups.reduce(
(count, group) =>
@@ -259,13 +274,13 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
0,
) > 1)
.map(turn => turn.turn),
[turns],
[focusedTurns],
)
const allTurnsCollapsed = collapsibleTurnIds.length > 0
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
const collapsibleAssistantIds = useMemo(() => {
const ids: number[] = []
for (const turn of turns) {
for (const turn of focusedTurns) {
const cells = turn.groups.flatMap(group => group.cells)
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]
@@ -275,7 +290,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
}
}
return ids
}, [turns])
}, [focusedTurns])
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
@@ -331,11 +346,18 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
allAssistantsCollapsed={allAssistantsCollapsed}
onToggleAllAssistants={toggleAllAssistants}
/>
<TrajectoryTimeline
turns={turns}
range={timelineRange}
onRangeChange={(range) => {
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
}}
/>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.id}
key={`${currentBranch.id}:${timelineRange?.start ?? 'all'}:${timelineRange?.end ?? 'all'}`}
requestNumbers={requestNumbers}
turns={turns}
turns={focusedTurns}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
@@ -1,75 +0,0 @@
// WaterfallView: span stats header over per-turn node-count lanes (P-I
// stand-in for duration lanes; deviation ledger #3). run_code turns
// additionally draw TRUTHFUL sub-call lanes: the dispatch start/settle pair
// carries per-sub-call wall time, so each sub-span's width is its real
// duration against the parent turn's dispatch window.
import { useMemo } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans, deriveSubSpans } from './spans.ts'
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
import css from './views.module.css'
/** Bar width scale: px per node, clamped so tiny windows still show a bar. */
const PX_PER_NODE = 14
const MIN_BAR_PX = 8
/** Sub-span lane width budget (the parent window scales into this). */
const SUB_LANE_PX = 220
/** Optional density override (test/standalone knob; the register site passes nothing). */
export interface WaterfallExtraProps {
/** Bar-lane density in px per node; defaults to 14. */
pxPerNode?: number
}
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
const scale = pxPerNode ?? PX_PER_NODE
const nodes = useSession(s => s.nodes)
const codeDispatches = useSession(s => s.codeDispatches)
const spans = useMemo(() => deriveSpans(nodes), [nodes])
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>No timing data</p></div>
return (
<>
<TrajectoryStatsHeader useSession={useSession} />
<div className={css.root}>
{spans.map((span, i) => (
<div key={span.turn}>
<div className={css.row} style={{ paddingLeft: i * 12 }}>
<span className={css.turnTag}>turn {span.turn}</span>
<span
className={css.bar}
style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
title={`${span.nodes} nodes`}
/>
{span.calls > 0 && (
<span
className={`${css.bar} ${css.barCalls}`}
style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
title={`${span.calls} tool calls`}
/>
)}
</div>
{(subSpans.get(span.turn) ?? []).map(lane => (
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
<span className={css.subTag}>{lane.name}</span>
<span
className={`${css.bar} ${css.barSub}`}
data-timing={lane.timing}
style={{
marginLeft: Math.round(lane.offsetFraction * SUB_LANE_PX),
width: Math.max(Math.round(lane.widthFraction * SUB_LANE_PX), 4),
}}
title={lane.timing === 'measured'
/* durationMs is non-null exactly when timing is measured. */
? `${lane.name} · ${((lane.durationMs ?? 0) / 1000).toFixed(2)} s`
: lane.timing === 'running' ? `${lane.name} · running` : `${lane.name} · duration unknown`}
/>
</div>
))}
</div>
))}
</div>
</>
)
}
@@ -1,6 +1,6 @@
/**
* Browser trajectory plugin contributing two entries to the conversation
* view slot without defining a service.
* Browser trajectory plugin contributing one entry to the conversation view
* slot without defining a service.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -8,7 +8,6 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// owning package) must be in the program for the register calls to type.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
import { WaterfallView } from './WaterfallView.tsx'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
@@ -20,9 +19,8 @@ import { WaterfallView } from './WaterfallView.tsx'
export const inject = ['slots', 'conversation', 'sessions']
/**
* Client plugin body: register the trajectory and waterfall view tabs. The
* registrations ride the slot service's effect wrapper (plugin unload
* removes both tabs).
* Client plugin body: register the trajectory view tab. The registration
* rides the slot service's effect wrapper, so plugin unload removes the tab.
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
@@ -39,6 +37,4 @@ export function apply(ctx: Context): void {
return { loadAllHistory: () => session.loadAllHistory() }
},
}, TrajectoryView)
ctx.slots.register(
{ name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView)
}
@@ -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
}
@@ -0,0 +1,110 @@
/** Time-domain projection and filtering for the trajectory overview. */
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
import type { TrajectoryTurnModel } from './layout.ts'
/** Inclusive absolute-time selection in Unix epoch milliseconds. */
export interface TrajectoryTimeRange {
start: number
end: number
}
/** One timed ledger record projected into the overview. */
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
index: number
kind: TrajectoryCellKind
label: string
lane: number
}
/** Full-domain model used by the overview. */
export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
spans: readonly TrajectoryTimelineSpan[]
}
function finite(value: number | null | undefined): value is number {
return value !== null && value !== undefined && Number.isFinite(value)
}
function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null {
if (!finite(cell.startedAt)) return null
const durationMs = finite(cell.timeSeconds)
? Math.max(0, cell.timeSeconds * 1_000)
: 0
return { start: cell.startedAt, end: cell.startedAt + durationMs }
}
function laneFor(kind: TrajectoryCellKind): number {
if (kind === 'tool' || kind === 'subtool') return 2
if (kind === 'message' || kind === 'compacted') return 1
return 0
}
/**
* Project every visible timed record into a stable three-lane overview.
* @param turns - Unfiltered trajectory layout.
* @returns Timeline model, or `null` when no record carries a start time.
*/
export function deriveTrajectoryTimeline(
turns: readonly TrajectoryTurnModel[],
): TrajectoryTimelineModel | null {
const spans = turns.flatMap(turn =>
turn.groups.flatMap(group =>
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
if (cell.requestOnly === true) return []
const range = cellRange(cell)
return range === null
? []
: [{
...range,
index: cell.index,
kind: cell.kind,
label: cell.text,
lane: laneFor(cell.kind),
}]
}),
),
)
if (spans.length === 0) return null
return {
start: Math.min(...spans.map(span => span.start)),
end: Math.max(...spans.map(span => span.end)),
spans,
}
}
function overlaps(cell: TrajectoryCellProps, range: TrajectoryTimeRange): boolean {
const timed = cellRange(cell)
return timed !== null && timed.start <= range.end && timed.end >= range.start
}
/**
* Keep records active at any point inside an inclusive selected interval.
* @param turns - Unfiltered trajectory layout.
* @param range - Absolute selected interval, or `null` for the full ledger.
* @returns A layout retaining original turn, group, and record identities.
*/
export function filterTrajectoryTimelineRange(
turns: readonly TrajectoryTurnModel[],
range: TrajectoryTimeRange | null,
): readonly TrajectoryTurnModel[] {
if (range === null) return turns
return turns.flatMap((turn): TrajectoryTurnModel[] => {
const groups = turn.groups.flatMap((group) => {
const cells = group.cells.filter(cell => overlaps(cell, range))
return cells.length === 0 ? [] : [{ ...group, cells }]
})
return groups.length === 0 ? [] : [{ ...turn, groups }]
})
}
/**
* Format a relative timeline offset with a compact unit.
* @param milliseconds - Non-negative relative offset.
* @returns Millisecond or second label.
*/
export function formatTimelineOffset(milliseconds: number): string {
if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
const seconds = milliseconds / 1_000
return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s`
}
@@ -1,4 +1,4 @@
/* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */
/* Full-bleed, fixed-height host for the trajectory ledger. */
.root {
--dsh-trajectory-toolbar-height: 40px;
@@ -13,16 +13,6 @@
background: var(--dsw-alias-bg-layer-1);
}
.empty {
display: grid;
flex: 1;
margin: 0;
padding: 24px;
place-items: center;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
.ledger {
display: flex;
flex: 1;
@@ -30,68 +20,3 @@
min-width: 0;
overflow: hidden;
}
/* Waterfall placeholder rows (shared module). */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 16px;
}
.turnTag {
flex: none;
width: 64px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
}
.bar {
height: 12px;
border-radius: 4px;
background: var(--dsw-alias-bg-skeleton);
}
.barCalls {
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.meta {
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
}
/* run_code sub-span lanes: one row per sub-dispatch under its turn row,
offset/width scaled to the dispatch window (real wall time). A running
lane pulses via reduced opacity until its settle arrives. */
.subRow {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.subTag {
flex: none;
width: 88px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
.barSub {
height: 8px;
background: var(--dsw-alias-state-business-primary);
}
.barSub[data-timing='running'] {
opacity: 0.45;
}
/* Settle-only replay entries: no measured span — hollow, not a solid bar. */
.barSub[data-timing='unknown'] {
background: transparent;
border: 1px dashed var(--dsw-alias-state-business-primary);
}
@@ -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 = () => {}