feat(ui-trajectory): enrich timeline timing interactions

This commit is contained in:
_Kerman
2026-08-03 13:48:03 +08:00
parent c8ece8325c
commit 74cd2e4bd9
5 changed files with 388 additions and 55 deletions
+11
View File
@@ -162,6 +162,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
await page.getByRole('tab', { name: 'Result' }).click()
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
const assistantSpan = page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').first()
await assistantSpan.hover()
const timingTooltip = page.getByRole('tooltip')
await timingTooltip.waitFor({ timeout: 5_000 })
await expect.poll(() => timingTooltip.textContent(), { timeout: 5_000 }).toMatch(/TTFT .* Decoding/)
const assistantTimingStyle = await assistantSpan.evaluate(node => ({
background: getComputedStyle(node).backgroundImage,
ttft: getComputedStyle(node).getPropertyValue('--trajectory-assistant-ttft'),
}))
expect(assistantTimingStyle.background).toContain('linear-gradient')
expect(assistantTimingStyle.ttft).toMatch(/%$/)
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
@@ -4,7 +4,8 @@
- button "Collapse calls": Calls
- img
- searchbox "Search trajectory"
- region "Trajectory timeline"
- region "Trajectory timeline":
- tooltip "ASSISTANT {{clock}}:40.549 AM → {{clock}}:42.091 AM Total 1.5 s · TTFT 368 ms · Decoding 1.2 s"
- table:
- rowgroup:
- row "SYSTEM, Initial System Prompt":
@@ -7,6 +7,10 @@
user-select: none;
}
.root :global([role='tooltip']) {
font: var(--dsw-font-xxxs-11);
}
.plot {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
@@ -53,6 +57,10 @@
touch-action: none;
}
.track[data-panning='true'] {
cursor: grabbing;
}
.empty {
position: absolute;
top: 50%;
@@ -105,8 +113,15 @@
.span {
position: absolute;
top: calc(var(--trajectory-span-lane) * 14px);
left: calc(var(--trajectory-span-left) + 1px);
width: max(2px, calc(var(--trajectory-span-width) - 2px));
left: calc(var(--trajectory-span-left) + var(--trajectory-span-gap));
width: max(
2px,
calc(
var(--trajectory-span-width)
- var(--trajectory-span-gap)
- var(--trajectory-span-gap)
)
);
height: 8px;
min-width: 2px;
border-radius: 1px;
@@ -127,23 +142,35 @@
}
.span[data-timeline-span='message'] {
background: color-mix(
--trajectory-assistant-decoding-color: 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(
--trajectory-assistant-ttft-color: color-mix(
in srgb,
var(--dsw-alias-state-warn-label) 62%,
var(--dsw-alias-label-tertiary)
var(--trajectory-assistant-decoding-color) 54%,
var(--dsw-alias-bg-layer-2)
);
background: var(--trajectory-assistant-decoding-color);
opacity: 1;
}
.span[data-timeline-span='message'][data-assistant-timing='true'] {
background: linear-gradient(
to right,
var(--trajectory-assistant-ttft-color) 0,
var(--trajectory-assistant-ttft-color) var(--trajectory-assistant-ttft),
var(--trajectory-assistant-decoding-color) var(--trajectory-assistant-ttft),
var(--trajectory-assistant-decoding-color) 100%
);
}
.span[data-timeline-span='tool'],
.span[data-timeline-span='subtool'] {
background: var(--dsw-alias-state-warn-label);
opacity: 1;
}
.span[data-error='true'] {
@@ -161,7 +188,7 @@
.span[data-hovered='true']:not([data-current='true']) {
z-index: 1;
opacity: 0.78;
opacity: 1;
box-shadow:
0 0 0 1px var(--dsw-alias-bg-layer-2),
0 0 0 2px color-mix(
@@ -4,7 +4,9 @@ import {
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
type PointerEvent,
} from 'react'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TrajectoryTurnModel } from './layout.ts'
import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
import {
deriveTrajectoryTimeline,
formatTimelineOffset,
@@ -18,6 +20,14 @@ const MINIMUM_ZOOM_OPERATIONS = 4
const EDGE_PAN_ZONE_FRACTION = 0.08
const EDGE_PAN_STEP_FRACTION = 0.025
const MAXIMUM_EDGE_PAN_PX = 32
const TIMELINE_TOOLTIP_DELAY_MS = 500
interface TimelineRecordDetail {
decodingMs?: number
durationMs?: number
startedAt?: number
ttftMs?: number
}
interface FractionRange {
start: number
@@ -29,6 +39,94 @@ interface HoverPoint {
recordIndex: number | null
}
interface PanGesture {
anchorClientX: number
anchorStart: number
moved: boolean
pannable: boolean
pointerId: number
}
function assistantTimingDetail(
metrics: AssistantMetricDetail | undefined,
): Pick<TimelineRecordDetail, 'ttftMs' | 'decodingMs'> {
const start = metrics?.stepStartTime
const first = metrics?.firstTokenTime
const completed = metrics?.completedTime
if (
metrics?.timingRecorded !== true
|| typeof start !== 'number'
|| typeof first !== 'number'
|| typeof completed !== 'number'
|| !Number.isFinite(start)
|| !Number.isFinite(first)
|| !Number.isFinite(completed)
|| first < start
|| completed < first
) return {}
return { ttftMs: first - start, decodingMs: completed - first }
}
function timelineRecordDetail(cell: TrajectoryCellProps): TimelineRecordDetail {
const durationMs = cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds)
? undefined
: Math.max(0, cell.timeSeconds * 1_000)
const startedAt = cell.startedAt === null || !Number.isFinite(cell.startedAt)
? undefined
: cell.startedAt
return {
...(durationMs === undefined ? {} : { durationMs }),
...(startedAt === undefined ? {} : { startedAt }),
...assistantTimingDetail(cell.assistantMetrics),
}
}
function timelineKindLabel(kind: TrajectoryCellKind): string {
switch (kind) {
case 'system': return 'SYSTEM'
case 'user': return 'USER'
case 'context': return 'CONTEXT'
case 'compacted': return 'COMPACTED'
case 'message': return 'ASSISTANT'
case 'tool': return 'TOOL'
case 'subtool': return 'SUBTOOL'
}
}
function formatRecordedTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
})
}
function timelineTooltipLabel(
kind: TrajectoryCellKind,
detail: TimelineRecordDetail | undefined,
): string {
const heading = timelineKindLabel(kind)
if (detail === undefined) return heading
const duration = detail.durationMs === undefined
? null
: `Total ${formatTimelineOffset(detail.durationMs)}`
const range = detail.startedAt === undefined
? null
: detail.durationMs === undefined
? `Started ${formatRecordedTime(detail.startedAt)}`
: `${formatRecordedTime(detail.startedAt)}${formatRecordedTime(
detail.startedAt + detail.durationMs,
)}`
const segments = detail.ttftMs === undefined || detail.decodingMs === undefined
? null
: `TTFT ${formatTimelineOffset(detail.ttftMs)} · Decoding ${formatTimelineOffset(
detail.decodingMs,
)}`
const timing = [duration, segments].filter(value => value !== null).join(' · ')
return [heading, range, timing].filter(value => value !== null && value !== '').join('\n')
}
/** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
@@ -105,14 +203,10 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
onRecordFocus,
}: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
const durationByIndex = useMemo(
const detailByIndex = useMemo(
() => new Map(turns.flatMap(turn =>
turn.groups.flatMap(group =>
group.cells.flatMap(cell =>
cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds)
? []
: [[cell.index, Math.max(0, cell.timeSeconds * 1_000)] as const],
),
group.cells.map(cell => [cell.index, timelineRecordDetail(cell)] as const),
),
)),
[turns],
@@ -123,10 +217,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
anchorClientX: number
recordIndex: number | null
} | null>(null)
const panRef = useRef<PanGesture | null>(null)
const rootRef = useRef<HTMLElement | null>(null)
const trackRef = useRef<HTMLDivElement | null>(null)
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
const [hover, setHover] = useState<HoverPoint | null>(null)
const [panning, setPanning] = useState(false)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
const [animateViewport, setAnimateViewport] = useState(false)
useEffect(() => {
@@ -267,6 +363,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button === 2) {
panRef.current = {
anchorClientX: event.clientX,
anchorStart: domainStart,
moved: false,
pannable: viewport !== null,
pointerId: event.pointerId,
}
if (viewport !== null) setAnimateViewport(false)
setPanning(true)
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId)
}
return
}
if (event.button !== 0) return
const anchor = fractionAt(event)
const anchorTime = domainStart + anchor * domainDuration
@@ -285,10 +396,24 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
const rect = event.currentTarget.getBoundingClientRect()
const fraction = fractionAt(event)
setHover({ fraction, recordIndex: recordIndexAt(event) })
const pan = panRef.current
if (pan !== null && pan.pointerId === event.pointerId) {
if (Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX) {
pan.moved = true
}
if (!pan.pannable) return
const delta = (event.clientX - pan.anchorClientX) / Math.max(1, rect.width)
const nextStart = Math.min(
Math.max(pan.anchorStart - delta * domainDuration, model.start),
model.end - domainDuration,
)
setViewport({ start: nextStart, end: nextStart + domainDuration })
return
}
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
let nextDomainStart = domainStart
if (viewport !== null) {
@@ -326,6 +451,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
const pan = panRef.current
if (pan !== null && pan.pointerId === event.pointerId) {
const moved = pan.moved
|| Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX
panRef.current = null
setPanning(false)
if (!moved) onRangeChange(null)
return
}
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
const pointFraction = fractionAt(event)
@@ -375,8 +509,10 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const onPointerCancel = () => {
dragRef.current = null
panRef.current = null
setDraft(null)
setHover(null)
setPanning(false)
}
return (
@@ -386,6 +522,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
<div
ref={trackRef}
className={css.track}
data-panning={panning || undefined}
aria-label="Timeline overview; drag horizontally to focus events"
tabIndex={0}
onKeyDown={onKeyDown}
@@ -394,7 +531,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
onPointerUp={onPointerEnd}
onPointerCancel={onPointerCancel}
onPointerLeave={() => {
if (dragRef.current === null) setHover(null)
if (dragRef.current === null && panRef.current === null) setHover(null)
}}
onDoubleClick={(event) => {
event.preventDefault()
@@ -402,9 +539,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}}
onContextMenu={(event) => {
event.preventDefault()
setAnimateViewport(false)
onRangeChange(null)
setViewport(null)
}}
>
{hover !== null && hover.recordIndex === null && draft === null && (
@@ -466,7 +600,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
className={css.lanes}
data-animate-viewport={animateViewport || undefined}
data-timeline-domain
aria-hidden="true"
style={projectedDomainStyle}
>
{model.spans
@@ -476,34 +609,51 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
.map((span) => {
const left = (span.start - model.start) / fullDuration
const width = (span.end - span.start) / fullDuration
const durationMs = durationByIndex.get(span.index)
const widthPercent = Math.max(width * 100, 0.35)
const detail = detailByIndex.get(span.index)
const ttftMs = detail?.ttftMs
const decodingMs = detail?.decodingMs
const ttftFraction = ttftMs === undefined
|| decodingMs === undefined
|| ttftMs + decodingMs <= 0
? null
: ttftMs / (ttftMs + decodingMs)
return (
<span
className={css.span}
data-timeline-span={span.kind}
data-timeline-record-index={span.index}
data-error={span.isError || undefined}
data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined}
data-hovered={hover?.recordIndex === span.index || undefined}
data-search-match={searchMatchIndexes === null
? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
data-selected={activeRange === null
? undefined
: span.start <= activeRange.end && span.end >= activeRange.start
? 'true'
: 'false'}
<Tooltip
key={span.index}
title={durationMs === undefined
? span.label
: `${span.label} · ${formatTimelineOffset(durationMs)}`}
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
'--trajectory-span-lane': span.lane,
} as CSSProperties}
/>
label={timelineTooltipLabel(span.kind, detail)}
side="bottom"
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
>
<span
aria-hidden="true"
className={css.span}
data-timeline-span={span.kind}
data-timeline-record-index={span.index}
data-assistant-timing={ttftFraction === null ? undefined : 'true'}
data-error={span.isError || undefined}
data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined}
data-hovered={hover?.recordIndex === span.index || undefined}
data-search-match={searchMatchIndexes === null
? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
data-selected={activeRange === null
? undefined
: span.start <= activeRange.end && span.end >= activeRange.start
? 'true'
: 'false'}
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${widthPercent}%`,
'--trajectory-span-gap': `clamp(0.25px, ${widthPercent * 0.08}%, 1px)`,
'--trajectory-span-lane': span.lane,
...(ttftFraction === null
? {}
: { '--trajectory-assistant-ttft': `${ttftFraction * 100}%` }),
} as CSSProperties}
/>
</Tooltip>
)
})}
</div>
@@ -9,7 +9,7 @@
*/
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -445,7 +445,7 @@ describe('tab switching in ConversationRoot', () => {
.toBe('outside')
fireEvent.contextMenu(plot)
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBeNull()
.toBe('outside')
})
it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
@@ -528,6 +528,57 @@ describe('timeline projection', () => {
}],
}] satisfies readonly TrajectoryTurnModel[]
it('splits assistant time into recorded TTFT and decoding proportions with a delayed tooltip', () => {
vi.useFakeTimers()
try {
const view = render(
<TrajectoryTimeline
turns={[{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
text: 'assistant',
startedAt: 1_000,
timeSeconds: 2,
assistantMetrics: {
timingRecorded: true,
stepStartTime: 1_000,
firstTokenTime: 1_500,
completedTime: 3_000,
usageProvided: false,
outputTokens: null,
},
}],
}],
}]}
mode="duration"
range={null}
onRangeChange={vi.fn()}
/>,
)
const span = view.container.querySelector<HTMLElement>(
'[data-timeline-span="message"]',
)
expect(span?.getAttribute('title')).toBeNull()
expect(span?.getAttribute('data-assistant-timing')).toBe('true')
expect(span?.style.getPropertyValue('--trajectory-assistant-ttft')).toBe('25%')
fireEvent.mouseEnter(span as HTMLElement)
act(() => { vi.advanceTimersByTime(499) })
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
const tooltip = view.container.querySelector<HTMLElement>('[role="tooltip"]')
expect(tooltip?.textContent).toContain('Total 2.0 s')
expect(tooltip?.textContent).toContain('TTFT 500 ms')
expect(tooltip?.textContent).toContain('Decoding 1.5 s')
} finally {
vi.useRealTimers()
}
})
it('cancels native scrolling across the timeline while zooming', () => {
render(
<TrajectoryTimeline
@@ -550,6 +601,99 @@ describe('timeline projection', () => {
})).toBe(false)
})
it('scales sequence gutters with narrow operation spans', () => {
const view = render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
onRangeChange={vi.fn()}
/>,
)
const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%')
expect(span?.style.getPropertyValue('--trajectory-span-gap'))
.toBe('clamp(0.25px, 0.8%, 1px)')
})
it('clears the selection without changing zoom on a zoomed right click', () => {
const onRangeChange = vi.fn()
const view = render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={{ start: 2, end: 4 }}
onRangeChange={onRangeChange}
/>,
)
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: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width')
expect(domainWidth).not.toBe('100%')
fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
expect(fireEvent.contextMenu(plot)).toBe(false)
fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
expect(onRangeChange).toHaveBeenCalledOnce()
expect(onRangeChange).toHaveBeenCalledWith(null)
expect(domain?.style.getPropertyValue('--trajectory-domain-width')).toBe(domainWidth)
})
it('clears the selection and suppresses the context menu at full zoom', () => {
const onRangeChange = vi.fn()
render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={{ start: 2, end: 4 }}
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
expect(fireEvent.contextMenu(plot)).toBe(false)
fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
expect(onRangeChange).toHaveBeenCalledOnce()
expect(onRangeChange).toHaveBeenCalledWith(null)
})
it('pans the zoomed viewport with a right-button drag without changing the selection', () => {
const onRangeChange = vi.fn()
const view = render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={{ start: 2, end: 4 }}
onRangeChange={onRangeChange}
/>,
)
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: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
const before = domain?.style.getPropertyValue('--trajectory-domain-left')
fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
expect(plot.getAttribute('data-panning')).toBe('true')
expect(fireEvent.contextMenu(plot)).toBe(false)
fireEvent.pointerMove(plot, { buttons: 2, clientX: 75, pointerId: 1 })
fireEvent.pointerUp(plot, { button: 2, clientX: 75, pointerId: 1 })
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).not.toBe(before)
expect(onRangeChange).not.toHaveBeenCalled()
expect(plot.getAttribute('data-panning')).toBeNull()
})
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
const onRangeChange = vi.fn()
const view = render(