diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index bcd0f1dc5f..b30b6d2575 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -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 () => { diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 205e711c19..2de3651526 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -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": diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 6a94b854d0..4f8885ecfc 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -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 { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 873ea09477..08e725437d 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -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 | 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 /** 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(null) const detailsResizeDrag = useRef(null) const tabHistory = useRef>(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 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index 26994038c1..5b7afdf89c 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -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; - } -} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index ce6cd66a73..e6014239aa 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -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 ( + + ) +} + /** 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(null) - const domainDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0)) + const [viewport, setViewport] = useState(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 (
-
- Overview - No timing data +
+ +
+ No timing data +
) @@ -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) => { + 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 (
-
- Overview - {summary} - {range !== null && ( - - )} -
-
-
) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css index d6e0b08db5..1225a5d8fe 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css @@ -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; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx index 7ff1b863bf..4e863b73ee 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx @@ -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({ Trajectory
+