feat(client): refine trajectory timeline interaction

This commit is contained in:
_Kerman
2026-07-28 18:02:19 +08:00
parent 1d4a6149cc
commit bbd9949d31
11 changed files with 577 additions and 272 deletions

View File

@@ -159,7 +159,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
const plot = page.getByLabel('Timeline overview; drag horizontally to filter events') const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
const before = await page.locator('tr[data-kind]').count() const before = await page.locator('tr[data-kind]').count()
const box = await plot.boundingBox() const box = await plot.boundingBox()
if (box === null) throw new Error('trajectory timeline plot has no layout box') if (box === null) throw new Error('trajectory timeline plot has no layout box')
@@ -167,11 +167,11 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.mouse.down() await page.mouse.down()
await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2) await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2)
await page.mouse.up() await page.mouse.up()
await page.getByRole('button', { name: 'Clear selection' }).waitFor() await expect.poll(() => page.locator('tr[data-timeline-focus="outside"]').count(), { timeout: 10_000 })
await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }) .toBeGreaterThan(0)
.toBeLessThan(before)
await page.getByRole('button', { name: 'Clear selection' }).click()
await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before) await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before)
await plot.click({ button: 'right' })
await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0)
}, 60_000) }, 60_000)
it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => { it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => {

View File

@@ -2,7 +2,7 @@
- text: Trajectory - text: Trajectory
- button "Collapse calls" - button "Collapse calls"
- button "Collapse turns" - button "Collapse turns"
- region "Trajectory timeline": Overview 9 timed events - region "Trajectory timeline"
- table: - table:
- rowgroup: - rowgroup:
- row "SYSTEM, Initial System Prompt": - row "SYSTEM, Initial System Prompt":

View File

@@ -76,7 +76,13 @@
.table tbody tr:not([data-collapsed-summary]) { .table tbody tr:not([data-collapsed-summary]) {
cursor: default; cursor: default;
outline: none; outline: none;
transition: background-color 120ms var(--ds-ease-in-out); transition:
background-color 120ms var(--ds-ease-in-out),
opacity 120ms var(--ds-ease-in-out);
}
.table tbody tr[data-timeline-focus='outside'] {
opacity: 0.24;
} }
.table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover { .table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover {

View File

@@ -1,6 +1,6 @@
/** Turn-aware trajectory event ledger with a local record inspector. */ /** Turn-aware trajectory event ledger with a local record inspector. */
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react' import type { CSSProperties, ReactNode } from 'react'
import { import {
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText, extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
@@ -217,6 +217,10 @@ export interface TrajectoryTableProps {
requestNumbers?: readonly TrajectoryRequestNumber[] requestNumbers?: readonly TrajectoryRequestNumber[]
/** Grouped records in display order. */ /** Grouped records in display order. */
turns: readonly TrajectoryTurnModel[] turns: readonly TrajectoryTurnModel[]
/** Record indexes emphasized by the active timeline focus. */
timelineFocusIndexes?: ReadonlySet<number> | null
/** Report the record currently selected in the local inspector. */
onSelectedIndexChange?: (index: number | null) => void
/** Turn ids whose rows after the first are folded into a summary. */ /** Turn ids whose rows after the first are folded into a summary. */
collapsedTurns: ReadonlySet<number> collapsedTurns: ReadonlySet<number>
/** Toggle one turn between folded and expanded. */ /** Toggle one turn between folded and expanded. */
@@ -1357,6 +1361,8 @@ function OverviewSection({
export function TrajectoryTable({ export function TrajectoryTable({
requestNumbers: sessionRequestNumbers, requestNumbers: sessionRequestNumbers,
turns, turns,
timelineFocusIndexes = null,
onSelectedIndexChange,
collapsedTurns, collapsedTurns,
onToggleTurn, onToggleTurn,
collapsedAssistants, collapsedAssistants,
@@ -1370,6 +1376,9 @@ export function TrajectoryTable({
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null) const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null) const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview'])) const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
useEffect(() => {
onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex])
const allRecords = flattenRecords(turns) const allRecords = flattenRecords(turns)
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers) const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const turnRecords = collapseTurnRecords(allRecords, collapsedTurns) const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
@@ -1570,6 +1579,9 @@ export function TrajectoryTable({
data-turn-end={record.turnEnd || undefined} data-turn-end={record.turnEnd || undefined}
data-collapsed-summary={record.collapsedSummaryKind} data-collapsed-summary={record.collapsedSummaryKind}
data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined} data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
data-timeline-focus={isCollapsedSummary || timelineFocusIndexes === null
? undefined
: timelineFocusIndexes.has(record.cell.index) ? 'inside' : 'outside'}
onClick={isRequestOnly onClick={isRequestOnly
? undefined ? undefined
: isCollapsedSummary : isCollapsedSummary

View File

@@ -1,130 +1,150 @@
.root { .root {
flex: none; flex: none;
padding: 8px 16px 12px;
border-bottom: 1px solid var(--dsw-alias-border-l2); border-bottom: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
user-select: none; user-select: none;
} }
.header { .plot {
display: flex; display: grid;
align-items: center; grid-template-columns: 44px minmax(0, 1fr);
min-height: 24px; height: 50px;
gap: 8px; overflow: hidden;
background: var(--dsw-alias-bg-layer-2);
} }
.title { .labels {
color: var(--dsw-alias-label-primary); position: relative;
font: var(--dsw-font-xs-13); border-right: 1px solid var(--dsw-alias-border-l1);
font-weight: 600;
}
.summary {
flex: 1;
color: var(--dsw-alias-label-caption); color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13); font: var(--dsw-font-xs-13);
font-size: 10px;
line-height: 1;
} }
.clear { .labels span {
flex: none; position: absolute;
padding: 2px 8px; right: 6px;
border: 0; display: flex;
border-radius: 4px; align-items: center;
color: var(--dsw-alias-state-business-primary); justify-content: flex-end;
background: transparent; height: 8px;
font: var(--dsw-font-xs-13); text-align: right;
cursor: pointer;
} }
.clear:hover { .labels span:nth-child(1) {
background: var(--dsw-alias-interactive-bg-hover); top: 7px;
} }
.clear:focus-visible { .labels span:nth-child(2) {
outline: 1px solid var(--dsw-alias-state-business-primary); top: 21px;
outline-offset: 1px;
} }
.plot { .labels span:nth-child(3) {
top: 35px;
}
.track {
position: relative; position: relative;
height: 72px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
background: var(--dsw-alias-bg-layer-2);
cursor: crosshair; cursor: crosshair;
touch-action: none; touch-action: none;
} }
.plot:focus-visible { .empty {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
.ticks {
position: absolute; position: absolute;
inset: 0 8px auto; top: 50%;
height: 20px; left: 50%;
border-bottom: 1px solid var(--dsw-alias-border-l1); transform: translate(-50%, -50%);
}
.tick {
position: absolute;
left: var(--trajectory-tick-left);
padding: 2px 4px;
transform: translateX(-50%);
white-space: nowrap;
color: var(--dsw-alias-label-caption); color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13); font: var(--dsw-font-xs-13);
} }
.tick:first-child { .track:focus-visible {
transform: none; outline: 1px solid var(--dsw-alias-state-business-primary);
} outline-offset: -1px;
.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 { .lanes {
position: absolute; position: absolute;
inset: 24px 8px 6px; 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 { .span {
position: absolute; position: absolute;
top: calc(var(--trajectory-span-lane) * 14px); top: calc(var(--trajectory-span-lane) * 14px);
left: var(--trajectory-span-left); left: calc(var(--trajectory-span-left) + 1px);
width: var(--trajectory-span-width); width: max(2px, calc(var(--trajectory-span-width) - 2px));
height: 8px; height: 8px;
min-width: 2px; min-width: 2px;
border-radius: 1px; border-radius: 1px;
background: var(--dsw-alias-label-tertiary); background: var(--dsw-alias-label-secondary);
opacity: 0.72; opacity: 0.78;
} }
.span[data-timeline-span='message'], .span[data-timeline-span='user'] {
.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); 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-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);
}
.selection { .selection {
position: absolute; position: absolute;
z-index: 1;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: var(--trajectory-selection-left); left: var(--trajectory-selection-left);
@@ -168,14 +188,3 @@
transparent transparent
); );
} }
@media (max-width: 720px) {
.root {
padding-right: 12px;
padding-left: 12px;
}
.plot {
height: 64px;
}
}

View File

@@ -1,19 +1,19 @@
/** Chrome-Network-style overview timeline for focusing the trajectory ledger. */ /** Chrome-Network-style overview timeline for focusing the trajectory ledger. */
import { import {
memo, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
type PointerEvent, type WheelEvent,
} from 'react' } from 'react'
import type { TrajectoryTurnModel } from './layout.ts' import type { TrajectoryTurnModel } from './layout.ts'
import { import {
deriveTrajectoryTimeline, deriveTrajectoryTimeline,
filterTrajectoryTimelineRange, type TrajectoryTimelineMode,
formatTimelineOffset,
type TrajectoryTimeRange, type TrajectoryTimeRange,
} from './timeline.ts' } from './timeline.ts'
import css from './TrajectoryTimeline.module.css' import css from './TrajectoryTimeline.module.css'
const TICK_COUNT = 5
const MINIMUM_DRAG_PX = 3 const MINIMUM_DRAG_PX = 3
const MINIMUM_ZOOM_OPERATIONS = 4
interface FractionRange { interface FractionRange {
start: number start: number
@@ -23,8 +23,11 @@ interface FractionRange {
/** Props for the fixed full-domain overview above the trajectory ledger. */ /** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps { export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[] turns: readonly TrajectoryTurnModel[]
mode: TrajectoryTimelineMode
range: TrajectoryTimeRange | null range: TrajectoryTimeRange | null
selectedIndex?: number | null
onRangeChange: (range: TrajectoryTimeRange | null) => void onRangeChange: (range: TrajectoryTimeRange | null) => void
onRecordFocus?: (index: number) => void
} }
function orderedRange(left: number, right: number): FractionRange { function orderedRange(left: number, right: number): FractionRange {
@@ -46,33 +49,77 @@ function rangeFraction(
) )
} }
function LaneLabels() {
return (
<div className={css.labels} aria-hidden="true">
<span>Input</span>
<span>Model</span>
<span>Tools</span>
</div>
)
}
/** Overview renderer with drag-to-filter and Escape/clear reset. */ /** Overview renderer with drag-to-filter and Escape/clear reset. */
export const TrajectoryTimeline = memo(function TrajectoryTimeline({ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
turns, turns,
mode,
range, range,
selectedIndex = null,
onRangeChange, onRangeChange,
onRecordFocus,
}: TrajectoryTimelineProps) { }: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns), [turns]) const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null) const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
const [draft, setDraft] = useState<FractionRange | null>(null) const [draft, setDraft] = useState<FractionRange | null>(null)
const domainDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0)) 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 const committed = model === null || range === null
? null ? null
: rangeFraction(range, model.start, domainDuration) : rangeFraction(range, domainStart, domainDuration)
const visibleRange = draft ?? committed const visibleRange = draft ?? committed
const focusedCount = useMemo( const activeRange = draft === null
() => range === null ? range
? model?.spans.length ?? 0 : {
: deriveTrajectoryTimeline(filterTrajectoryTimelineRange(turns, range))?.spans.length ?? 0, start: domainStart + draft.start * domainDuration,
[model?.spans.length, range, turns], end: domainStart + draft.end * domainDuration,
) }
if (model === null) { if (model === null) {
return ( return (
<section className={css.root} aria-label="Trajectory timeline"> <section className={css.root} aria-label="Trajectory timeline">
<div className={css.header}> <div className={css.plot}>
<span className={css.title}>Overview</span> <LaneLabels />
<span className={css.summary}>No timing data</span> <div className={css.track}>
<span className={css.empty}>No timing data</span>
</div>
</div> </div>
</section> </section>
) )
@@ -85,8 +132,8 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const commit = (fraction: FractionRange) => { const commit = (fraction: FractionRange) => {
onRangeChange({ onRangeChange({
start: model.start + fraction.start * domainDuration, start: domainStart + fraction.start * domainDuration,
end: model.start + fraction.end * domainDuration, end: domainStart + fraction.end * domainDuration,
}) })
} }
@@ -115,6 +162,17 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
setDraft(null) setDraft(null)
if ((selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX) { if ((selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX) {
onRangeChange(null) onRangeChange(null)
const point = domainStart + selected.start * domainDuration
const nearest = model.spans.reduce((candidate, span) => {
const candidateDistance = point < candidate.start
? candidate.start - point
: point > candidate.end ? point - candidate.end : 0
const spanDistance = point < span.start
? span.start - point
: point > span.end ? point - span.end : 0
return spanDistance < candidateDistance ? span : candidate
})
onRecordFocus?.(nearest.index)
} else { } else {
commit(selected) commit(selected)
} }
@@ -131,85 +189,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
setDraft(null) setDraft(null)
} }
const ticks = Array.from({ length: TICK_COUNT }, (_, index) => { const onWheel = (event: WheelEvent<HTMLDivElement>) => {
const fraction = index / (TICK_COUNT - 1) event.preventDefault()
return { const rect = event.currentTarget.getBoundingClientRect()
fraction, const anchorFraction =
label: formatTimelineOffset(fraction * domainDuration), clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
const nextDuration = Math.min(
fullDuration,
Math.max(
Math.min(mode === 'actual' ? 20 : MINIMUM_ZOOM_OPERATIONS, fullDuration),
domainDuration * Math.exp(event.deltaY * 0.0015),
),
)
if (nextDuration >= fullDuration * 0.999) {
setViewport(null)
return
} }
}) const anchorTime = domainStart + anchorFraction * domainDuration
const summary = range === null const nextStart = Math.min(
? `${model.spans.length} timed events` Math.max(anchorTime - anchorFraction * nextDuration, model.start),
: `${focusedCount} of ${model.spans.length} events · ${formatTimelineOffset(range.start - model.start)}${formatTimelineOffset(range.end - model.start)}` model.end - nextDuration,
)
setViewport({ start: nextStart, end: nextStart + nextDuration })
}
return ( return (
<section className={css.root} aria-label="Trajectory timeline"> <section className={css.root} aria-label="Trajectory timeline">
<div className={css.header}> <div className={css.plot}>
<span className={css.title}>Overview</span> <LaneLabels />
<span className={css.summary} aria-live="polite">{summary}</span> <div
{range !== null && ( className={css.track}
<button aria-label="Timeline overview; drag horizontally to focus events"
className={css.clear} tabIndex={0}
type="button" onKeyDown={onKeyDown}
onClick={() => { onPointerDown={onPointerDown}
onRangeChange(null) onPointerMove={onPointerMove}
}} onPointerUp={onPointerEnd}
> onPointerCancel={onPointerCancel}
Clear selection onWheel={onWheel}
</button> onContextMenu={(event) => {
)} event.preventDefault()
</div> onRangeChange(null)
<div setViewport(null)
className={css.plot} }}
aria-label="Timeline overview; drag horizontally to filter events" >
tabIndex={0} {visibleRange !== null && (
onKeyDown={onKeyDown} <div
onPointerDown={onPointerDown} className={css.selection}
onPointerMove={onPointerMove} data-dragging={draft === null ? undefined : 'true'}
onPointerUp={onPointerEnd} aria-hidden="true"
onPointerCancel={onPointerCancel} style={{
> '--trajectory-selection-left': `${visibleRange.start * 100}%`,
<div className={css.ticks} aria-hidden="true"> '--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
{ticks.map(tick => ( } as CSSProperties}
<span />
className={css.tick} )}
key={tick.fraction} <div className={css.turnBoundaries} aria-hidden="true">
style={{ '--trajectory-tick-left': `${tick.fraction * 100}%` } as CSSProperties} {model.turnBoundaries
> .slice(1)
{tick.label} .filter(boundary =>
</span> 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
return (
<span
className={css.span}
data-timeline-span={span.kind}
data-current={span.index === selectedIndex || undefined}
data-selected={activeRange === null
? undefined
: span.start <= activeRange.end && span.end >= activeRange.start
? 'true'
: 'false'}
key={span.index}
title={span.label}
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>
<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> </div>
</section> </section>
) )

View File

@@ -39,6 +39,61 @@
gap: 2px; gap: 2px;
} }
.modeSwitch {
display: inline-flex;
flex: none;
align-items: center;
height: 24px;
margin-right: 5px;
padding: 0 7px;
gap: 6px;
border: 0;
border-radius: 4px;
color: var(--dsw-alias-label-tertiary);
background: transparent;
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.modeSwitch:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.modeSwitch:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
.modeTrack {
position: relative;
display: inline-block;
width: 26px;
height: 14px;
border-radius: 7px;
background: var(--dsw-alias-border-l2);
transition: background-color 120ms var(--ds-ease-in-out);
}
.modeThumb {
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--dsw-alias-bg-layer-1);
transition: transform 120ms var(--ds-ease-in-out);
}
.modeSwitch[aria-checked='true'] .modeTrack {
background: var(--dsw-alias-state-business-primary);
}
.modeSwitch[aria-checked='true'] .modeThumb {
transform: translateX(12px);
}
.action { .action {
display: inline-flex; display: inline-flex;
flex: none; flex: none;

View File

@@ -3,6 +3,10 @@
import css from './TrajectoryToolbar.module.css' import css from './TrajectoryToolbar.module.css'
export interface TrajectoryToolbarProps { export interface TrajectoryToolbarProps {
/** Whether the timeline uses recorded durations instead of equal-width operations. */
actualTime: boolean
/** Select the timeline's recorded-time or equal-width projection. */
onActualTimeChange: (actualTime: boolean) => void
/** Number of turns containing more than one row. */ /** Number of turns containing more than one row. */
collapsibleTurns: number collapsibleTurns: number
/** Whether every collapsible turn is currently folded. */ /** Whether every collapsible turn is currently folded. */
@@ -23,6 +27,8 @@ export interface TrajectoryToolbarProps {
* @returns the toolbar element. * @returns the toolbar element.
*/ */
export function TrajectoryToolbar({ export function TrajectoryToolbar({
actualTime,
onActualTimeChange,
collapsibleTurns, collapsibleTurns,
allTurnsCollapsed, allTurnsCollapsed,
onToggleAllTurns, onToggleAllTurns,
@@ -37,6 +43,18 @@ export function TrajectoryToolbar({
<span className={css.title}>Trajectory</span> <span className={css.title}>Trajectory</span>
</div> </div>
<div className={css.actions}> <div className={css.actions}>
<button
type="button"
className={css.modeSwitch}
role="switch"
aria-checked={actualTime}
onClick={() => { onActualTimeChange(!actualTime) }}
>
<span>Actual time</span>
<span className={css.modeTrack} aria-hidden="true">
<span className={css.modeThumb} />
</span>
</button>
<button <button
type="button" type="button"
className={css.action} className={css.action}

View File

@@ -17,7 +17,9 @@ import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx' import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
import { deriveTrajectoryLayout } from './layout.ts' import { deriveTrajectoryLayout } from './layout.ts'
import { import {
filterTrajectoryTimelineRange, type TrajectoryTimeRange, trajectoryTimelineFocusIndexes,
type TrajectoryTimelineMode,
type TrajectoryTimeRange,
} from './timeline.ts' } from './timeline.ts'
import css from './views.module.css' import css from './views.module.css'
@@ -81,6 +83,9 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
branchId: number branchId: number
range: TrajectoryTimeRange range: TrajectoryTimeRange
} | null>(null) } | null>(null)
const [timelineMode, setTimelineMode] = useState<TrajectoryTimelineMode>('sequence')
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
const ledgerRef = useRef<HTMLDivElement>(null)
const nodes = useSession(s => s.nodes) const nodes = useSession(s => s.nodes)
const inspection = useSession(s => s.inspection) const inspection = useSession(s => s.inspection)
const hasMore = useSession(s => s.hasMore) const hasMore = useSession(s => s.hasMore)
@@ -260,12 +265,33 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
const timelineRange = timelineSelection?.branchId === currentBranch.id const timelineRange = timelineSelection?.branchId === currentBranch.id
? timelineSelection.range ? timelineSelection.range
: null : null
const focusedTurns = useMemo( const timelineFocusIndexes = useMemo(
() => filterTrajectoryTimelineRange(turns, timelineRange), () => timelineRange === null
[timelineRange, turns], ? null
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
[timelineMode, timelineRange, turns],
) )
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) {
first.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
focusedRows[Math.floor((focusedRows.length - 1) / 2)]
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [timelineFocusIndexes])
const collapsibleTurnIds = useMemo( const collapsibleTurnIds = useMemo(
() => focusedTurns () => turns
.filter(turn => .filter(turn =>
turn.groups.reduce( turn.groups.reduce(
(count, group) => (count, group) =>
@@ -274,13 +300,13 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
0, 0,
) > 1) ) > 1)
.map(turn => turn.turn), .map(turn => turn.turn),
[focusedTurns], [turns],
) )
const allTurnsCollapsed = collapsibleTurnIds.length > 0 const allTurnsCollapsed = collapsibleTurnIds.length > 0
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn)) && collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
const collapsibleAssistantIds = useMemo(() => { const collapsibleAssistantIds = useMemo(() => {
const ids: number[] = [] const ids: number[] = []
for (const turn of focusedTurns) { for (const turn of turns) {
const cells = turn.groups.flatMap(group => group.cells) const cells = turn.groups.flatMap(group => group.cells)
for (let i = 0; i < cells.length; i++) { for (let i = 0; i < cells.length; i++) {
const cell = cells[i] const cell = cells[i]
@@ -290,7 +316,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
} }
} }
return ids return ids
}, [focusedTurns]) }, [turns])
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0 const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index)) && collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
@@ -339,6 +365,11 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
return ( return (
<div className={css.root}> <div className={css.root}>
<TrajectoryToolbar <TrajectoryToolbar
actualTime={timelineMode === 'actual'}
onActualTimeChange={(actualTime) => {
setTimelineMode(actualTime ? 'actual' : 'sequence')
setTimelineSelection(null)
}}
collapsibleTurns={collapsibleTurnIds.length} collapsibleTurns={collapsibleTurnIds.length}
allTurnsCollapsed={allTurnsCollapsed} allTurnsCollapsed={allTurnsCollapsed}
onToggleAllTurns={toggleAllTurns} onToggleAllTurns={toggleAllTurns}
@@ -348,16 +379,25 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
/> />
<TrajectoryTimeline <TrajectoryTimeline
turns={turns} turns={turns}
mode={timelineMode}
range={timelineRange} range={timelineRange}
selectedIndex={selectedTimelineIndex}
onRangeChange={(range) => { onRangeChange={(range) => {
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range }) setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
}} }}
onRecordFocus={(index) => {
ledgerRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}}
/> />
<div className={css.ledger}> <div ref={ledgerRef} className={css.ledger}>
<TrajectoryTable <TrajectoryTable
key={`${currentBranch.id}:${timelineRange?.start ?? 'all'}:${timelineRange?.end ?? 'all'}`} key={currentBranch.id}
requestNumbers={requestNumbers} requestNumbers={requestNumbers}
turns={focusedTurns} turns={turns}
timelineFocusIndexes={timelineFocusIndexes}
onSelectedIndexChange={setSelectedTimelineIndex}
collapsedTurns={collapsedTurns} collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn} onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants} collapsedAssistants={collapsedAssistants}

View File

@@ -1,15 +1,18 @@
/** Time-domain projection and filtering for the trajectory overview. */ /** Operation-sequence and recorded-time projections for the trajectory overview. */
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
import type { TrajectoryTurnModel } from './layout.ts' import type { TrajectoryTurnModel } from './layout.ts'
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
/** Inclusive absolute-time selection in Unix epoch milliseconds. */ /** Horizontal projection used by the trajectory timeline. */
export type TrajectoryTimelineMode = 'sequence' | 'actual'
/** Inclusive selection in the active timeline projection's domain. */
export interface TrajectoryTimeRange { export interface TrajectoryTimeRange {
start: number start: number
end: number end: number
} }
/** One timed ledger record projected into the overview. */ /** One ledger record projected into the active timeline domain. */
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange { export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
index: number index: number
kind: TrajectoryCellKind kind: TrajectoryCellKind
@@ -17,9 +20,22 @@ export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
lane: number lane: number
} }
/** One turn boundary in the active timeline domain. */
export interface TrajectoryTimelineTurnBoundary {
turn: number
time: number
}
/** Full-domain model used by the overview. */ /** Full-domain model used by the overview. */
export interface TrajectoryTimelineModel extends TrajectoryTimeRange { export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
spans: readonly TrajectoryTimelineSpan[] spans: readonly TrajectoryTimelineSpan[]
turnBoundaries: readonly TrajectoryTimelineTurnBoundary[]
}
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 { function finite(value: number | null | undefined): value is number {
@@ -34,22 +50,58 @@ function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null {
return { start: cell.startedAt, end: cell.startedAt + durationMs } 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. * Project every visible record into a stable three-lane timeline.
* @param turns - Unfiltered trajectory layout. * @param turns - Unfiltered trajectory layout.
* @returns Timeline model, or `null` when no record carries a start time. * @param mode - Equal-width operation sequence or recorded wall-clock timing.
* @returns Timeline model, or `null` when no record is visible.
*/ */
export function deriveTrajectoryTimeline( export function deriveTrajectoryTimeline(
turns: readonly TrajectoryTurnModel[], turns: readonly TrajectoryTurnModel[],
mode: TrajectoryTimelineMode = 'sequence',
): TrajectoryTimelineModel | null { ): TrajectoryTimelineModel | null {
const spans = turns.flatMap(turn => if (mode === 'actual') return deriveActualTimeline(turns)
turn.groups.flatMap(group => 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 deriveActualTimeline(
turns: readonly TrajectoryTurnModel[],
): 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[] => { group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
if (cell.requestOnly === true) return [] if (cell.requestOnly === true) return []
const range = cellRange(cell) const range = cellRange(cell)
@@ -63,48 +115,53 @@ export function deriveTrajectoryTimeline(
lane: laneFor(cell.kind), 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 (previousTurnEnd !== null) {
removedUserIdle += Math.max(0, turnStart - previousTurnEnd)
}
spans.push(...rawSpans.map(span => ({
...span,
start: span.start - removedUserIdle,
end: span.end - removedUserIdle,
})))
turnBoundaries.push({
turn: turn.turn,
time: turnStart - removedUserIdle,
})
previousTurnEnd = previousTurnEnd === null
? turnEnd
: Math.max(previousTurnEnd, turnEnd)
}
if (spans.length === 0) return null if (spans.length === 0) return null
return { return {
start: Math.min(...spans.map(span => span.start)), start: Math.min(...spans.map(span => span.start)),
end: Math.max(...spans.map(span => span.end)), end: Math.max(...spans.map(span => span.end)),
spans, spans,
turnBoundaries,
} }
} }
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. * Identify records active at any point inside an inclusive selected interval.
* @param turns - Unfiltered trajectory layout. * @param turns - Unfiltered trajectory layout.
* @param range - Absolute selected interval, or `null` for the full ledger. * @param range - Selected interval in the active projection.
* @returns A layout retaining original turn, group, and record identities. * @param mode - Equal-width operation sequence or recorded wall-clock timing.
* @returns Record indexes inside the focus interval.
*/ */
export function filterTrajectoryTimelineRange( export function trajectoryTimelineFocusIndexes(
turns: readonly TrajectoryTurnModel[], turns: readonly TrajectoryTurnModel[],
range: TrajectoryTimeRange | null, range: TrajectoryTimeRange,
): readonly TrajectoryTurnModel[] { mode: TrajectoryTimelineMode = 'sequence',
if (range === null) return turns ): ReadonlySet<number> {
return turns.flatMap((turn): TrajectoryTurnModel[] => { const model = deriveTrajectoryTimeline(turns, mode)
const groups = turn.groups.flatMap((group) => { return new Set(
const cells = group.cells.filter(cell => overlaps(cell, range)) model?.spans
return cells.length === 0 ? [] : [{ ...group, cells }] .filter(span => span.start <= range.end && span.end >= range.start)
}) .map(span => span.index),
return groups.length === 0 ? [] : [{ ...turn, groups }] )
})
}
/**
* Format a relative timeline offset with a compact unit.
* @param milliseconds - Non-negative relative offset.
* @returns Millisecond or second label.
*/
export function formatTimelineOffset(milliseconds: number): string {
if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
const seconds = milliseconds / 1_000
return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s`
} }

View File

@@ -19,18 +19,13 @@ import type {
ConversationSnapshot, RequestView, SessionId, SessionListState, WorkspaceListState, ConversationSnapshot, RequestView, SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import type { TrajectoryTurnModel } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/layout.ts'
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
import {
deriveTrajectoryTimeline,
filterTrajectoryTimelineRange,
formatTimelineOffset,
} from '@deepseek-ai/dsh-client-ui-trajectory/src/client/timeline.ts'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
import { TrajectoryView } from '../src/client/TrajectoryView.tsx'
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
const SID = 's1' as SessionId const SID = 's1' as SessionId
afterEach(cleanup) afterEach(cleanup)
@@ -229,11 +224,11 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull() expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
}) })
it('dragging the overview focuses overlapping records and clear restores the ledger', async () => { it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
const b = await bench() const b = await bench()
mount(b.slots) mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
const plot = screen.getByLabelText('Timeline overview; drag horizontally to filter events') const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}), toJSON: () => ({}),
@@ -242,10 +237,11 @@ describe('tab switching in ConversationRoot', () => {
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 }) fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 }) fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
expect(screen.queryByRole('row', { name: /USER/ })).toBeNull() expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
expect(screen.getByRole('button', { name: 'Clear selection' })).toBeTruthy() .toBe('outside')
fireEvent.click(screen.getByRole('button', { name: 'Clear selection' })) fireEvent.contextMenu(plot)
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBeNull()
}) })
it('empty window keeps the toolbar and reports no timing data', async () => { it('empty window keeps the toolbar and reports no timing data', async () => {
@@ -272,25 +268,57 @@ describe('timeline projection', () => {
}], }],
}] satisfies readonly TrajectoryTurnModel[] }] satisfies readonly TrajectoryTurnModel[]
it('uses real start/duration timing and stable semantic lanes', () => { it('uses equal-width operation slots and stable semantic lanes', () => {
expect(deriveTrajectoryTimeline(turns)).toEqual({ expect(deriveTrajectoryTimeline(turns)).toEqual({
start: 1_000, start: 0,
end: 3_000, end: 3,
spans: [ spans: [
{ {
index: 1, kind: 'message', label: 'assistant', lane: 1, start: 1_000, end: 2_000, index: 1, kind: 'message', label: 'assistant', lane: 1, start: 0, end: 1,
}, },
{ index: 2, kind: 'tool', label: 'bash', lane: 2, start: 2_000, end: 3_000 }, { index: 2, kind: 'tool', label: 'bash', lane: 2, start: 1, end: 2 },
{ index: 3, kind: 'user', label: 'unknown', lane: 0, start: 2, end: 3 },
], ],
turnBoundaries: [{ turn: 1, time: 0 }],
}) })
expect(formatTimelineOffset(999)).toBe('999 ms')
expect(formatTimelineOffset(1_500)).toBe('1.5 s')
}) })
it('filters inclusively and drops records without known timing', () => { it('ignores durations and idle gaps while retaining turn boundaries', () => {
const focused = filterTrajectoryTimelineRange(turns, { start: 2_000, end: 2_000 }) const separatedTurns = [
expect(focused[0]?.groups[0]?.cells.map(cell => cell.index)).toEqual([1, 2]) {
expect(filterTrajectoryTimelineRange(turns, null)).toBe(turns) turn: 1,
groups: [{
title: 'Step 1',
cells: [
{ index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
{ index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
],
}],
},
{
turn: 2,
groups: [{
title: 'Step 1',
cells: [
{ index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
],
}],
},
] satisfies readonly TrajectoryTurnModel[]
expect(deriveTrajectoryTimeline(separatedTurns)).toMatchObject({
start: 0,
end: 3,
spans: [
{ index: 1, start: 0, end: 1 },
{ index: 2, start: 1, end: 2 },
{ index: 3, start: 2, end: 3 },
],
turnBoundaries: [
{ turn: 1, time: 0 },
{ turn: 2, time: 2 },
],
})
}) })
it('empty inputs produce no model and the standalone view reports its empty form', () => { it('empty inputs produce no model and the standalone view reports its empty form', () => {