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
+5 -5
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 () => {
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 box = await plot.boundingBox()
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.move(box.x + box.width * 0.9, box.y + box.height / 2)
await page.mouse.up()
await page.getByRole('button', { name: 'Clear selection' }).waitFor()
await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 })
.toBeLessThan(before)
await page.getByRole('button', { name: 'Clear selection' }).click()
await expect.poll(() => page.locator('tr[data-timeline-focus="outside"]').count(), { timeout: 10_000 })
.toBeGreaterThan(0)
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)
it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => {
@@ -2,7 +2,7 @@
- text: Trajectory
- button "Collapse calls"
- button "Collapse turns"
- region "Trajectory timeline": Overview 9 timed events
- region "Trajectory timeline"
- table:
- rowgroup:
- row "SYSTEM, Initial System Prompt":
@@ -76,7 +76,13 @@
.table tbody tr:not([data-collapsed-summary]) {
cursor: default;
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 {
@@ -1,6 +1,6 @@
/** 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 {
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
@@ -217,6 +217,10 @@ export interface TrajectoryTableProps {
requestNumbers?: readonly TrajectoryRequestNumber[]
/** Grouped records in display order. */
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. */
collapsedTurns: ReadonlySet<number>
/** Toggle one turn between folded and expanded. */
@@ -1357,6 +1361,8 @@ function OverviewSection({
export function TrajectoryTable({
requestNumbers: sessionRequestNumbers,
turns,
timelineFocusIndexes = null,
onSelectedIndexChange,
collapsedTurns,
onToggleTurn,
collapsedAssistants,
@@ -1370,6 +1376,9 @@ export function TrajectoryTable({
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
useEffect(() => {
onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex])
const allRecords = flattenRecords(turns)
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
@@ -1570,6 +1579,9 @@ export function TrajectoryTable({
data-turn-end={record.turnEnd || undefined}
data-collapsed-summary={record.collapsedSummaryKind}
data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
data-timeline-focus={isCollapsedSummary || timelineFocusIndexes === null
? undefined
: timelineFocusIndexes.has(record.cell.index) ? 'inside' : 'outside'}
onClick={isRequestOnly
? undefined
: isCollapsedSummary
@@ -1,130 +1,150 @@
.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;
.plot {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
height: 50px;
overflow: hidden;
background: var(--dsw-alias-bg-layer-2);
}
.title {
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xs-13);
font-weight: 600;
}
.summary {
flex: 1;
.labels {
position: relative;
border-right: 1px solid var(--dsw-alias-border-l1);
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
font-size: 10px;
line-height: 1;
}
.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;
.labels span {
position: absolute;
right: 6px;
display: flex;
align-items: center;
justify-content: flex-end;
height: 8px;
text-align: right;
}
.clear:hover {
background: var(--dsw-alias-interactive-bg-hover);
.labels span:nth-child(1) {
top: 7px;
}
.clear:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
.labels span:nth-child(2) {
top: 21px;
}
.plot {
.labels span:nth-child(3) {
top: 35px;
}
.track {
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 {
.empty {
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;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
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: '';
.track:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: -1px;
}
.lanes {
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 {
position: absolute;
top: calc(var(--trajectory-span-lane) * 14px);
left: var(--trajectory-span-left);
width: var(--trajectory-span-width);
left: calc(var(--trajectory-span-left) + 1px);
width: max(2px, calc(var(--trajectory-span-width) - 2px));
height: 8px;
min-width: 2px;
border-radius: 1px;
background: var(--dsw-alias-label-tertiary);
opacity: 0.72;
background: var(--dsw-alias-label-secondary);
opacity: 0.78;
}
.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'] {
.span[data-timeline-span='user'] {
background: var(--dsw-alias-state-business-primary);
}
.span[data-timeline-span='context'] {
background: color-mix(
in srgb,
var(--dsw-alias-state-success-primary) 68%,
var(--dsw-alias-label-secondary)
);
}
.span[data-timeline-span='message'] {
background: color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
var(--dsw-alias-state-error-secondary)
);
}
.span[data-timeline-span='tool'] {
background: var(--dsw-alias-state-warn-label);
}
.span[data-timeline-span='subtool'] {
background: color-mix(
in srgb,
var(--dsw-alias-state-warn-label) 62%,
var(--dsw-alias-label-tertiary)
);
}
.span[data-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 {
position: absolute;
z-index: 1;
top: 0;
bottom: 0;
left: var(--trajectory-selection-left);
@@ -168,14 +188,3 @@
transparent
);
}
@media (max-width: 720px) {
.root {
padding-right: 12px;
padding-left: 12px;
}
.plot {
height: 64px;
}
}
@@ -1,19 +1,19 @@
/** Chrome-Network-style overview timeline for focusing the trajectory ledger. */
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'
import type { TrajectoryTurnModel } from './layout.ts'
import {
deriveTrajectoryTimeline,
filterTrajectoryTimelineRange,
formatTimelineOffset,
type TrajectoryTimelineMode,
type TrajectoryTimeRange,
} from './timeline.ts'
import css from './TrajectoryTimeline.module.css'
const TICK_COUNT = 5
const MINIMUM_DRAG_PX = 3
const MINIMUM_ZOOM_OPERATIONS = 4
interface FractionRange {
start: number
@@ -23,8 +23,11 @@ interface FractionRange {
/** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
mode: TrajectoryTimelineMode
range: TrajectoryTimeRange | null
selectedIndex?: number | null
onRangeChange: (range: TrajectoryTimeRange | null) => void
onRecordFocus?: (index: number) => void
}
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. */
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
turns,
mode,
range,
selectedIndex = null,
onRangeChange,
onRecordFocus,
}: 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 [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
? null
: rangeFraction(range, model.start, domainDuration)
: rangeFraction(range, domainStart, 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],
)
const activeRange = draft === null
? range
: {
start: domainStart + draft.start * domainDuration,
end: domainStart + draft.end * domainDuration,
}
if (model === null) {
return (
<section className={css.root} aria-label="Trajectory timeline">
<div className={css.header}>
<span className={css.title}>Overview</span>
<span className={css.summary}>No timing data</span>
<div className={css.plot}>
<LaneLabels />
<div className={css.track}>
<span className={css.empty}>No timing data</span>
</div>
</div>
</section>
)
@@ -85,8 +132,8 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const commit = (fraction: FractionRange) => {
onRangeChange({
start: model.start + fraction.start * domainDuration,
end: model.start + fraction.end * domainDuration,
start: domainStart + fraction.start * domainDuration,
end: domainStart + fraction.end * domainDuration,
})
}
@@ -115,6 +162,17 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
setDraft(null)
if ((selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX) {
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 {
commit(selected)
}
@@ -131,85 +189,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
setDraft(null)
}
const ticks = Array.from({ length: TICK_COUNT }, (_, index) => {
const fraction = index / (TICK_COUNT - 1)
return {
fraction,
label: formatTimelineOffset(fraction * domainDuration),
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
const anchorFraction =
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
const nextDuration = Math.min(
fullDuration,
Math.max(
Math.min(mode === 'actual' ? 20 : MINIMUM_ZOOM_OPERATIONS, fullDuration),
domainDuration * Math.exp(event.deltaY * 0.0015),
),
)
if (nextDuration >= fullDuration * 0.999) {
setViewport(null)
return
}
})
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)}`
const anchorTime = domainStart + anchorFraction * domainDuration
const nextStart = Math.min(
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
model.end - nextDuration,
)
setViewport({ start: nextStart, end: nextStart + nextDuration })
}
return (
<section className={css.root} aria-label="Trajectory timeline">
<div className={css.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 className={css.plot}>
<LaneLabels />
<div
className={css.track}
aria-label="Timeline overview; drag horizontally to focus events"
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerCancel}
onWheel={onWheel}
onContextMenu={(event) => {
event.preventDefault()
onRangeChange(null)
setViewport(null)
}}
>
{visibleRange !== null && (
<div
className={css.selection}
data-dragging={draft === null ? undefined : 'true'}
aria-hidden="true"
style={{
'--trajectory-selection-left': `${visibleRange.start * 100}%`,
'--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
} as CSSProperties}
/>
)}
<div className={css.turnBoundaries} aria-hidden="true">
{model.turnBoundaries
.slice(1)
.filter(boundary =>
boundary.time >= domainStart
&& boundary.time <= domainStart + domainDuration)
.map(boundary => (
<span
className={css.turnBoundary}
data-turn={boundary.turn}
key={boundary.turn}
style={{
'--trajectory-turn-left':
`${(boundary.time - domainStart) / domainDuration * 100}%`,
} as CSSProperties}
/>
))}
</div>
<div className={css.lanes} aria-hidden="true">
{model.spans
.filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
.map((span) => {
const left = (span.start - domainStart) / domainDuration
const width = (span.end - span.start) / domainDuration
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 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>
)
@@ -39,6 +39,61 @@
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 {
display: inline-flex;
flex: none;
@@ -3,6 +3,10 @@
import css from './TrajectoryToolbar.module.css'
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. */
collapsibleTurns: number
/** Whether every collapsible turn is currently folded. */
@@ -23,6 +27,8 @@ export interface TrajectoryToolbarProps {
* @returns the toolbar element.
*/
export function TrajectoryToolbar({
actualTime,
onActualTimeChange,
collapsibleTurns,
allTurnsCollapsed,
onToggleAllTurns,
@@ -37,6 +43,18 @@ export function TrajectoryToolbar({
<span className={css.title}>Trajectory</span>
</div>
<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
type="button"
className={css.action}
@@ -17,7 +17,9 @@ import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
import { deriveTrajectoryLayout } from './layout.ts'
import {
filterTrajectoryTimelineRange, type TrajectoryTimeRange,
trajectoryTimelineFocusIndexes,
type TrajectoryTimelineMode,
type TrajectoryTimeRange,
} from './timeline.ts'
import css from './views.module.css'
@@ -81,6 +83,9 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
branchId: number
range: TrajectoryTimeRange
} | 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 inspection = useSession(s => s.inspection)
const hasMore = useSession(s => s.hasMore)
@@ -260,12 +265,33 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
const timelineRange = timelineSelection?.branchId === currentBranch.id
? timelineSelection.range
: null
const focusedTurns = useMemo(
() => filterTrajectoryTimelineRange(turns, timelineRange),
[timelineRange, turns],
const timelineFocusIndexes = useMemo(
() => timelineRange === null
? 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(
() => focusedTurns
() => turns
.filter(turn =>
turn.groups.reduce(
(count, group) =>
@@ -274,13 +300,13 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
0,
) > 1)
.map(turn => turn.turn),
[focusedTurns],
[turns],
)
const allTurnsCollapsed = collapsibleTurnIds.length > 0
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
const collapsibleAssistantIds = useMemo(() => {
const ids: number[] = []
for (const turn of focusedTurns) {
for (const turn of turns) {
const cells = turn.groups.flatMap(group => group.cells)
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]
@@ -290,7 +316,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
}
}
return ids
}, [focusedTurns])
}, [turns])
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
@@ -339,6 +365,11 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
return (
<div className={css.root}>
<TrajectoryToolbar
actualTime={timelineMode === 'actual'}
onActualTimeChange={(actualTime) => {
setTimelineMode(actualTime ? 'actual' : 'sequence')
setTimelineSelection(null)
}}
collapsibleTurns={collapsibleTurnIds.length}
allTurnsCollapsed={allTurnsCollapsed}
onToggleAllTurns={toggleAllTurns}
@@ -348,16 +379,25 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
/>
<TrajectoryTimeline
turns={turns}
mode={timelineMode}
range={timelineRange}
selectedIndex={selectedTimelineIndex}
onRangeChange={(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
key={`${currentBranch.id}:${timelineRange?.start ?? 'all'}:${timelineRange?.end ?? 'all'}`}
key={currentBranch.id}
requestNumbers={requestNumbers}
turns={focusedTurns}
turns={turns}
timelineFocusIndexes={timelineFocusIndexes}
onSelectedIndexChange={setSelectedTimelineIndex}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
@@ -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 { 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 {
start: 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 {
index: number
kind: TrajectoryCellKind
@@ -17,9 +20,22 @@ export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
lane: number
}
/** One turn boundary in the active timeline domain. */
export interface TrajectoryTimelineTurnBoundary {
turn: number
time: number
}
/** Full-domain model used by the overview. */
export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
spans: readonly TrajectoryTimelineSpan[]
turnBoundaries: readonly TrajectoryTimelineTurnBoundary[]
}
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 {
@@ -34,22 +50,58 @@ function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null {
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.
* @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(
turns: readonly TrajectoryTurnModel[],
mode: TrajectoryTimelineMode = 'sequence',
): TrajectoryTimelineModel | null {
const spans = turns.flatMap(turn =>
turn.groups.flatMap(group =>
if (mode === 'actual') return deriveActualTimeline(turns)
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[] => {
if (cell.requestOnly === true) return []
const range = cellRange(cell)
@@ -63,48 +115,53 @@ export function deriveTrajectoryTimeline(
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
return {
start: Math.min(...spans.map(span => span.start)),
end: Math.max(...spans.map(span => span.end)),
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 range - Absolute selected interval, or `null` for the full ledger.
* @returns A layout retaining original turn, group, and record identities.
* @param range - Selected interval in the active projection.
* @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[],
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`
range: TrajectoryTimeRange,
mode: TrajectoryTimelineMode = 'sequence',
): ReadonlySet<number> {
const model = deriveTrajectoryTimeline(turns, mode)
return new Set(
model?.spans
.filter(span => span.start <= range.end && span.end >= range.start)
.map(span => span.index),
)
}
@@ -19,18 +19,13 @@ import type {
ConversationSnapshot, RequestView, SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import 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 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
afterEach(cleanup)
@@ -229,11 +224,11 @@ describe('tab switching in ConversationRoot', () => {
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()
mount(b.slots)
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({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
@@ -242,10 +237,11 @@ describe('tab switching in ConversationRoot', () => {
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
expect(screen.queryByRole('row', { name: /USER/ })).toBeNull()
expect(screen.getByRole('button', { name: 'Clear selection' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Clear selection' }))
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBe('outside')
fireEvent.contextMenu(plot)
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBeNull()
})
it('empty window keeps the toolbar and reports no timing data', async () => {
@@ -272,25 +268,57 @@ describe('timeline projection', () => {
}],
}] 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({
start: 1_000,
end: 3_000,
start: 0,
end: 3,
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', () => {
const focused = filterTrajectoryTimelineRange(turns, { start: 2_000, end: 2_000 })
expect(focused[0]?.groups[0]?.cells.map(cell => cell.index)).toEqual([1, 2])
expect(filterTrajectoryTimelineRange(turns, null)).toBe(turns)
it('ignores durations and idle gaps while retaining turn boundaries', () => {
const separatedTurns = [
{
turn: 1,
groups: [{
title: 'Step 1',
cells: [
{ index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
{ index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
],
}],
},
{
turn: 2,
groups: [{
title: 'Step 1',
cells: [
{ index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
],
}],
},
] satisfies readonly TrajectoryTurnModel[]
expect(deriveTrajectoryTimeline(separatedTurns)).toMatchObject({
start: 0,
end: 3,
spans: [
{ index: 1, start: 0, end: 1 },
{ index: 2, start: 1, end: 2 },
{ index: 3, start: 2, end: 3 },
],
turnBoundaries: [
{ turn: 1, time: 0 },
{ turn: 2, time: 2 },
],
})
})
it('empty inputs produce no model and the standalone view reports its empty form', () => {