diff --git a/src/__tests__/unit/agent-session-turn-timeline.test.ts b/src/__tests__/unit/agent-session-turn-timeline.test.ts new file mode 100644 index 00000000..3922f1c7 --- /dev/null +++ b/src/__tests__/unit/agent-session-turn-timeline.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { + formatToolCallCount, + layoutTimeline, + pickSegmentLabel, + TIMELINE_LABEL_PADDING_PX, + TIMELINE_MIN_SEGMENT_PX, + turnLabelParts, + turnTooltip, +} from '@/components/agents/session/turnTimelineLayout'; + +const total = ({ widths, gap }: { widths: number[]; gap: number }) => + widths.reduce((sum, width) => sum + width, 0) + gap * Math.max(0, widths.length - 1); + +describe('layoutTimeline', () => { + it('fills exactly the available width with a single turn', () => { + const layout = layoutTimeline([1200], 640); + expect(layout.widths).toEqual([640]); + expect(layout.gap).toBe(0); + }); + + it('keeps the proportions between turns', () => { + const layout = layoutTimeline([1000, 3000, 2000], 600); + expect(total(layout)).toBeCloseTo(600, 6); + const [a, b, c] = layout.widths; + expect(b / a).toBeCloseTo(3, 6); + expect(c / a).toBeCloseTo(2, 6); + }); + + it('stays within the same width as turns keep arriving', () => { + const width = 720; + const durations: number[] = []; + for (let turn = 0; turn < 500; turn += 1) { + durations.push(((turn * 7919) % 45_000) + 50); + const layout = layoutTimeline(durations, width); + expect(layout.widths).toHaveLength(durations.length); + expect(total(layout)).toBeLessThanOrEqual(width + 1e-6); + expect(total(layout)).toBeGreaterThan(width - 1e-6); + expect(layout.widths.every((w) => w > 0)).toBe(true); + } + }); + + it('keeps short turns visible and the rest proportional', () => { + const layout = layoutTimeline([10, 60_000, 30_000], 300); + expect(layout.widths[0]).toBeCloseTo(TIMELINE_MIN_SEGMENT_PX, 6); + expect(layout.widths[1] / layout.widths[2]).toBeCloseTo(2, 6); + expect(total(layout)).toBeCloseTo(300, 6); + }); + + it('shares the width equally when no turn has a duration', () => { + const layout = layoutTimeline([0, 0, 0, 0], 400); + expect(total(layout)).toBeCloseTo(400, 6); + expect(new Set(layout.widths.map((w) => w.toFixed(6))).size).toBe(1); + }); + + it('never exceeds the width when there are more turns than pixels', () => { + const layout = layoutTimeline(Array.from({ length: 2000 }, (_, i) => (i % 5) * 1000), 500); + expect(layout.gap).toBe(0); + expect(total(layout)).toBeLessThanOrEqual(500 + 1e-6); + }); + + it('returns zero widths before the strip has been measured', () => { + expect(layoutTimeline([1000, 2000], 0)).toEqual({ widths: [0, 0], gap: 0 }); + expect(layoutTimeline([], 500)).toEqual({ widths: [], gap: 0 }); + }); +}); + +describe('turn labels', () => { + // A monospace stand-in for canvas measurement: 6px per character. + const measure = (text: string) => text.length * 6; + const pad = TIMELINE_LABEL_PADDING_PX * 2; + + it('counts tool calls, including none and one', () => { + expect(formatToolCallCount(0)).toBe('0 tool calls'); + expect(formatToolCallCount(1)).toBe('1 tool call'); + expect(formatToolCallCount(2)).toBe('2 tool calls'); + }); + + it('always puts turn number, duration and tool calls in the tooltip', () => { + expect(turnTooltip(1, '45.7s', 2, false)).toBe('Turn 1 · 45.7s · 2 tool calls'); + expect(turnTooltip(7, '850ms', 0, false)).toBe('Turn 7 · 850ms · 0 tool calls'); + expect(turnTooltip(3, '1.2m', 1, true)).toBe('Turn 3 · 1.2m · 1 tool call · Failed'); + }); + + it('shows duration and tool calls in a wide segment', () => { + const parts = turnLabelParts('45.7s', 2); + expect(parts.full).toBe('45.7s · 2 tool calls'); + expect(pickSegmentLabel(measure(parts.full) + pad, parts, measure)).toBe('45.7s · 2 tool calls'); + expect(pickSegmentLabel(400, turnLabelParts('3.1s', 0), measure)).toBe('3.1s · 0 tool calls'); + }); + + it('falls back to the duration, then to nothing, as the segment narrows', () => { + const parts = turnLabelParts('45.7s', 2); + expect(pickSegmentLabel(measure(parts.full) + pad - 1, parts, measure)).toBe('45.7s'); + expect(pickSegmentLabel(measure('45.7s') + pad, parts, measure)).toBe('45.7s'); + expect(pickSegmentLabel(measure('45.7s') + pad - 1, parts, measure)).toBeNull(); + expect(pickSegmentLabel(TIMELINE_MIN_SEGMENT_PX, parts, measure)).toBeNull(); + expect(pickSegmentLabel(0, parts, measure)).toBeNull(); + }); +}); diff --git a/src/components/agents/session/AgentSessionView.module.css b/src/components/agents/session/AgentSessionView.module.css index cc1b0220..799a6fe5 100644 --- a/src/components/agents/session/AgentSessionView.module.css +++ b/src/components/agents/session/AgentSessionView.module.css @@ -28,19 +28,42 @@ * looking for when a session feels slow, and a list of timestamps does not * show it. */ -.timelineScroll { +.timelineStrip { border-bottom: 1px solid var(--mantine-color-gray-3); flex-shrink: 0; + min-width: 0; + overflow: hidden; +} + +.timelineTrack { + display: flex; + flex-wrap: nowrap; + width: 100%; + overflow: hidden; } .timelineSegment { - height: 1.25rem; + flex: none; + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + padding: 0; + overflow: hidden; border-radius: 2px; background: var(--mantine-color-blue-4); - flex-shrink: 0; + color: var(--mantine-color-white); transition: background 120ms ease; } +.timelineLabel { + padding: 0 4px; + overflow: hidden; + white-space: nowrap; + line-height: 1; + pointer-events: none; +} + .timelineSegment:hover { background: var(--mantine-color-blue-6); } diff --git a/src/components/agents/session/AgentSessionView.tsx b/src/components/agents/session/AgentSessionView.tsx index 3daf06bc..c910b439 100644 --- a/src/components/agents/session/AgentSessionView.tsx +++ b/src/components/agents/session/AgentSessionView.tsx @@ -81,6 +81,7 @@ import { isContinuableSession, sessionSourceLabel } from '../studio/SessionList' import CompareVersionsDrawer from '../studio/CompareVersionsDrawer'; import { versionSelectData } from '../studio/StartSessionModal'; import SessionSidePanel from './SessionSidePanel'; +import TurnTimeline, { DEFAULT_TIMELINE_ZOOM, TIMELINE_ZOOM_LEVELS } from './TurnTimeline'; import LiveToolCalls, { appendLiveText, applyLiveToolEvent, isGenerating, type LiveSegment, type LiveToolEvent, } from './LiveToolCalls'; @@ -113,10 +114,6 @@ export interface AgentSessionViewProps { /** How much of a payload is shown before "Show the whole payload". */ const PAYLOAD_CLIP_CHARS = 1200; -/** Timeline zoom steps, in pixels-per-second of turn latency. */ -const ZOOM_LEVELS = [2, 6, 18, 54]; -const DEFAULT_ZOOM = 1; - /** A failed run, with the server's classification of what failed. */ class TurnFailure extends Error { constructor(message: string, readonly type?: string) { @@ -161,7 +158,7 @@ export default function AgentSessionView({ agentId, sessionId }: AgentSessionVie const readOnly = !isContinuableSession(sessionSource); const sessionContext = session?.metadata?.runtimeContext as Record | undefined; const [search, setSearch] = useState(''); - const [zoom, setZoom] = useState(DEFAULT_ZOOM); + const [zoom, setZoom] = useState(DEFAULT_TIMELINE_ZOOM); const [overrideOpen, setOverrideOpen] = useState(false); const [runtimeContextJson, setRuntimeContextJson] = useState(''); const [pinnedVersion, setPinnedVersion] = useState(searchParams.get('version') ?? ''); @@ -547,8 +544,8 @@ export default function AgentSessionView({ agentId, sessionId }: AgentSessionVie setZoom((value) => Math.min(ZOOM_LEVELS.length - 1, value + 1))} - disabled={zoom === ZOOM_LEVELS.length - 1} + onClick={() => setZoom((value) => Math.min(TIMELINE_ZOOM_LEVELS.length - 1, value + 1))} + disabled={zoom === TIMELINE_ZOOM_LEVELS.length - 1} > @@ -556,29 +553,7 @@ export default function AgentSessionView({ agentId, sessionId }: AgentSessionVie {timeline.length > 0 ? ( - - - {timeline.map(({ index, message }) => { - const seconds = (message.latencyMs ?? 0) / 1000; - const failed = message.role === 'error' - || Boolean(message.stopReason) - || message.steps?.some(stepFailed); - return ( - - goToTurn(index)} - className={`${classes.timelineSegment} ${failed ? classes.timelineSegmentFailed : ''}`} - style={{ width: Math.max(10, seconds * ZOOM_LEVELS[zoom]) }} - /> - - ); - })} - - + ) : null}
diff --git a/src/components/agents/session/TurnTimeline.tsx b/src/components/agents/session/TurnTimeline.tsx new file mode 100644 index 00000000..8772f7f2 --- /dev/null +++ b/src/components/agents/session/TurnTimeline.tsx @@ -0,0 +1,141 @@ +'use client'; + +/** + * The turn strip above a session transcript: one segment per answer, sized + * by how long that answer took, always exactly as wide as the panel. + * + * A new answer re-divides the same width rather than pushing the strip past + * the edge — scrolling sideways to find the slow turn defeats the point of a + * strip that exists to make the slow turn obvious at a glance. + */ + +import { Box, Tooltip, UnstyledButton } from '@mantine/core'; +import { useElementSize } from '@mantine/hooks'; +import { useEffect, useMemo, useState } from 'react'; +import { formatDuration } from '@/lib/utils/tracingUtils'; +import { stepFailed, type ChatMessage } from './sessionTypes'; +import { layoutTimeline, pickSegmentLabel, turnLabelParts, turnTooltip } from './turnTimelineLayout'; +import classes from './AgentSessionView.module.css'; + +/** + * Zoom steps. The width is fixed to the panel, so zooming changes how tall + * the strip is and how large its labels are — the thinnest step is a bare + * bar with no labels at all. + */ +export const TIMELINE_ZOOM_LEVELS = [ + { height: 6, fontSize: 0 }, + { height: 16, fontSize: 10 }, + { height: 22, fontSize: 11 }, + { height: 30, fontSize: 12 }, +] as const; +export const DEFAULT_TIMELINE_ZOOM = 2; + +const LABEL_FONT_WEIGHT = 500; + +export interface TurnTimelineEntry { + index: number; + message: ChatMessage; +} + +interface TurnTimelineProps { + entries: TurnTimelineEntry[]; + zoom: number; + onSelect: (index: number) => void; +} + +/** + * Measures label text in the strip's own font, so a label is shown only when + * it really fits. Re-measures once web fonts finish loading — measuring with + * the fallback font would pick labels a pixel or two too wide. + */ +function useTextMeasurer(element: HTMLElement | null, fontSize: number) { + const [fontsReady, setFontsReady] = useState(false); + useEffect(() => { + let active = true; + if (typeof document === 'undefined' || !document.fonts) { + setFontsReady(true); + return; + } + void document.fonts.ready.then(() => { + if (active) setFontsReady(true); + }); + return () => { + active = false; + }; + }, []); + + return useMemo(() => { + const fallback = (text: string) => text.length * fontSize * 0.62; + if (!element || fontSize <= 0 || typeof document === 'undefined') return fallback; + const context = document.createElement('canvas').getContext('2d'); + if (!context) return fallback; + const family = window.getComputedStyle(element).fontFamily || 'sans-serif'; + context.font = `${LABEL_FONT_WEIGHT} ${fontSize}px ${family}`; + const cache = new Map(); + return (text: string) => { + let width = cache.get(text); + if (width === undefined) { + width = Math.ceil(context.measureText(text).width); + cache.set(text, width); + } + return width; + }; + // fontsReady only triggers a re-measure once fonts have loaded. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [element, fontSize, fontsReady]); +} + +export default function TurnTimeline({ entries, zoom, onSelect }: TurnTimelineProps) { + // Measured on the track, inside the strip's padding: that is the width + // the segments and gaps must add up to. + const { ref, width } = useElementSize(); + const [trackElement, setTrackElement] = useState(null); + const level = TIMELINE_ZOOM_LEVELS[Math.min(Math.max(zoom, 0), TIMELINE_ZOOM_LEVELS.length - 1)]; + const measure = useTextMeasurer(trackElement, level.fontSize); + + const durations = useMemo(() => entries.map(({ message }) => message.latencyMs ?? 0), [entries]); + const layout = useMemo(() => layoutTimeline(durations, width), [durations, width]); + + return ( + +
{ + ref.current = node; + setTrackElement(node); + }} + className={classes.timelineTrack} + style={{ gap: layout.gap, height: level.height }} + > + {entries.map(({ index, message }, position) => { + const failed = message.role === 'error' + || Boolean(message.stopReason) + || Boolean(message.steps?.some(stepFailed)); + const toolCalls = message.steps?.length ?? 0; + const duration = formatDuration(message.latencyMs); + const segmentWidth = layout.widths[position] ?? 0; + const details = turnTooltip(position + 1, duration, toolCalls, failed); + const label = level.fontSize > 0 + ? pickSegmentLabel(segmentWidth, turnLabelParts(duration, toolCalls), measure) + : null; + return ( + + onSelect(index)} + className={`${classes.timelineSegment} ${failed ? classes.timelineSegmentFailed : ''}`} + style={{ + width: segmentWidth, + height: level.height, + fontSize: level.fontSize || undefined, + fontWeight: LABEL_FONT_WEIGHT, + }} + > + {label ? {label} : null} + + + ); + })} +
+
+ ); +} diff --git a/src/components/agents/session/turnTimelineLayout.ts b/src/components/agents/session/turnTimelineLayout.ts new file mode 100644 index 00000000..c14629dd --- /dev/null +++ b/src/components/agents/session/turnTimelineLayout.ts @@ -0,0 +1,105 @@ +/** + * Lays out the turn strip above a session transcript so it always fits the + * width it is given. + * + * Every segment's width is proportional to its turn's duration, but a turn + * that took 50ms next to one that took two minutes would otherwise vanish, so + * each segment gets a floor. Segments pinned to the floor are taken out of the + * proportional share and the rest is re-divided among the others — the same + * "freeze and redistribute" pass flexbox does for `min-width` — which keeps + * the proportions between every un-pinned segment exact and the total equal + * to the available width, however many turns there are. + */ + +/** The narrowest a segment is drawn while there is room for that. */ +export const TIMELINE_MIN_SEGMENT_PX = 4; + +export interface TimelineLayout { + /** One width per duration, in the same order, in pixels. */ + widths: number[]; + /** Space between neighbouring segments, in pixels. */ + gap: number; +} + +/** Tighter gaps as the strip fills up, so gaps never eat the segments. */ +export function timelineGap(count: number, availableWidth: number): number { + if (count <= 1) return 0; + const perSegment = availableWidth / count; + if (perSegment >= 12) return 2; + if (perSegment >= 5) return 1; + return 0; +} + +export function layoutTimeline(durationsMs: number[], availableWidth: number): TimelineLayout { + const count = durationsMs.length; + const width = Math.max(0, availableWidth); + if (count === 0 || width === 0) return { widths: durationsMs.map(() => 0), gap: 0 }; + + const gap = timelineGap(count, width); + const usable = Math.max(0, width - gap * (count - 1)); + const floor = Math.min(TIMELINE_MIN_SEGMENT_PX, usable / count); + const weights = durationsMs.map((ms) => (Number.isFinite(ms) && ms > 0 ? ms : 0)); + + const pinned = new Array(count).fill(false); + let pinnedCount = 0; + for (;;) { + const remaining = usable - pinnedCount * floor; + const totalWeight = weights.reduce((sum, weight, i) => (pinned[i] ? sum : sum + weight), 0); + if (totalWeight === 0) { + // Nothing left with a measurable duration: share what is left equally. + const share = (count - pinnedCount) > 0 ? remaining / (count - pinnedCount) : 0; + return { widths: weights.map((_, i) => (pinned[i] ? floor : share)), gap }; + } + const scale = remaining / totalWeight; + let changed = false; + weights.forEach((weight, i) => { + if (!pinned[i] && weight * scale < floor) { + pinned[i] = true; + pinnedCount += 1; + changed = true; + } + }); + if (!changed) { + return { widths: weights.map((weight, i) => (pinned[i] ? floor : weight * scale)), gap }; + } + } +} + +/** Horizontal padding a segment keeps around its label, per side. */ +export const TIMELINE_LABEL_PADDING_PX = 4; + +export function formatToolCallCount(count: number): string { + return `${count} tool call${count === 1 ? '' : 's'}`; +} + +export interface TurnLabelParts { + /** e.g. `45.7s · 2 tool calls` — shown when the segment is wide enough. */ + full: string; + /** e.g. `45.7s` — the fallback for a narrower segment. */ + duration: string; +} + +export function turnLabelParts(duration: string, toolCalls: number): TurnLabelParts { + return { full: `${duration} · ${formatToolCallCount(toolCalls)}`, duration }; +} + +/** What the hover card says about a turn, whatever fits inside the segment. */ +export function turnTooltip(turnNumber: number, duration: string, toolCalls: number, failed: boolean): string { + return [`Turn ${turnNumber}`, duration, formatToolCallCount(toolCalls), ...(failed ? ['Failed'] : [])].join(' · '); +} + +/** + * The longest label that fits inside a segment of `segmentWidth` pixels: + * duration and tool calls, then the duration alone, then nothing. + */ +export function pickSegmentLabel( + segmentWidth: number, + parts: TurnLabelParts, + measure: (text: string) => number, +): string | null { + const room = segmentWidth - TIMELINE_LABEL_PADDING_PX * 2; + if (room <= 0) return null; + if (measure(parts.full) <= room) return parts.full; + if (measure(parts.duration) <= room) return parts.duration; + return null; +}