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 files changed
+388 -55

No files matched your search

@@ -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>