feat(client): add trajectory timing overview
This commit is contained in:
@@ -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 = () => {}
|
||||
|
||||
Reference in New Issue
Block a user