From 74cd2e4bd9538aa5e0bb10a5cfedd8d0466edd7b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 13:48:03 +0800 Subject: [PATCH] feat(ui-trajectory): enrich timeline timing interactions --- apps/web/tests/navigation-panes.e2e.ts | 11 + .../navigation-panes/trajectory.expected.md | 3 +- .../src/client/TrajectoryTimeline.module.css | 55 +++-- .../src/client/TrajectoryTimeline.tsx | 226 +++++++++++++++--- .../client/ui-trajectory/tests/views.spec.tsx | 148 +++++++++++- 5 files changed, 388 insertions(+), 55 deletions(-) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 35f77b96ec..af4799a759 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -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) diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 788b8a3233..704190b9f2 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -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": diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index 1ca5ab2627..4d548e64d8 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -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( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 0fd0825f95..87d7cdcd34 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -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 { + 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(null) const rootRef = useRef(null) const trackRef = useRef(null) const [draft, setDraft] = useState(null) const [hover, setHover] = useState(null) + const [panning, setPanning] = useState(false) const [viewport, setViewport] = useState(null) const [animateViewport, setAnimateViewport] = useState(false) useEffect(() => { @@ -267,6 +363,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } const onPointerDown = (event: PointerEvent) => { + 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) => { - 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) => { + 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({
{ - 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 ( - = activeRange.start - ? 'true' - : 'false'} + + label={timelineTooltipLabel(span.kind, detail)} + side="bottom" + delayMs={TIMELINE_TOOLTIP_DELAY_MS} + > + ) })}
diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index fca6608950..6b7be01ec3 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -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( + , + ) + const span = view.container.querySelector( + '[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('[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( { })).toBe(false) }) + it('scales sequence gutters with narrow operation spans', () => { + const view = render( + , + ) + const span = view.container.querySelector('[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( + , + ) + 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('[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( + , + ) + 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( + , + ) + 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('[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(