From 0221d66420b1f7b917100ff46baf159b22745d37 Mon Sep 17 00:00:00 2001 From: Bor4brn <117600305+Bor4brn@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:19:04 +0300 Subject: [PATCH] feat(agents): fit session turn timeline to the transcript width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn strip above the session transcript now always fits the panel: new turns re-divide the same width instead of extending it, segment widths stay proportional to turn duration (with a small floor so short turns stay visible), and there is no horizontal scrolling. Segments show "duration · N tool calls" when there is room, fall back to the duration alone, then to no text. The tooltip always shows the turn number, duration and tool call count (including 0), plus "Failed" for failed turns. Click-to-jump and failed-turn colors are kept; zoom now changes strip height and label size. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../unit/agent-session-turn-timeline.test.ts | 100 +++++++++++++ .../session/AgentSessionView.module.css | 29 +++- .../agents/session/AgentSessionView.tsx | 35 +---- .../agents/session/TurnTimeline.tsx | 141 ++++++++++++++++++ .../agents/session/turnTimelineLayout.ts | 105 +++++++++++++ 5 files changed, 377 insertions(+), 33 deletions(-) create mode 100644 src/__tests__/unit/agent-session-turn-timeline.test.ts create mode 100644 src/components/agents/session/TurnTimeline.tsx create mode 100644 src/components/agents/session/turnTimelineLayout.ts 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; +}