Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/__tests__/unit/agent-session-turn-timeline.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
29 changes: 26 additions & 3 deletions src/components/agents/session/AgentSessionView.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
35 changes: 5 additions & 30 deletions src/components/agents/session/AgentSessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -161,7 +158,7 @@ export default function AgentSessionView({ agentId, sessionId }: AgentSessionVie
const readOnly = !isContinuableSession(sessionSource);
const sessionContext = session?.metadata?.runtimeContext as Record<string, unknown> | 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') ?? '');
Expand Down Expand Up @@ -547,38 +544,16 @@ export default function AgentSessionView({ agentId, sessionId }: AgentSessionVie
<ActionIcon
size="sm"
variant="subtle"
onClick={() => 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}
>
<IconZoomIn size={14} />
</ActionIcon>
</Tooltip>
</Group>

{timeline.length > 0 ? (
<ScrollArea type="hover" scrollbarSize={4} className={classes.timelineScroll}>
<Group gap={2} wrap="nowrap" px="xs" py={6}>
{timeline.map(({ index, message }) => {
const seconds = (message.latencyMs ?? 0) / 1000;
const failed = message.role === 'error'
|| Boolean(message.stopReason)
|| message.steps?.some(stepFailed);
return (
<Tooltip
key={index}
withArrow
label={`${formatDuration(message.latencyMs)}${message.steps?.length ? ` · ${message.steps.length} tool calls` : ''}`}
>
<UnstyledButton
onClick={() => goToTurn(index)}
className={`${classes.timelineSegment} ${failed ? classes.timelineSegmentFailed : ''}`}
style={{ width: Math.max(10, seconds * ZOOM_LEVELS[zoom]) }}
/>
</Tooltip>
);
})}
</Group>
</ScrollArea>
<TurnTimeline entries={timeline} zoom={zoom} onSelect={goToTurn} />
) : null}

<div className={classes.panelBody}>
Expand Down
141 changes: 141 additions & 0 deletions src/components/agents/session/TurnTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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<string, number>();
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<HTMLDivElement>();
const [trackElement, setTrackElement] = useState<HTMLDivElement | null>(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 (
<Box px="xs" py={6} className={classes.timelineStrip}>
<div
ref={(node) => {
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 (
<Tooltip key={index} withArrow label={details}>
<UnstyledButton
aria-label={details}
onClick={() => onSelect(index)}
className={`${classes.timelineSegment} ${failed ? classes.timelineSegmentFailed : ''}`}
style={{
width: segmentWidth,
height: level.height,
fontSize: level.fontSize || undefined,
fontWeight: LABEL_FONT_WEIGHT,
}}
>
{label ? <span className={classes.timelineLabel}>{label}</span> : null}
</UnstyledButton>
</Tooltip>
);
})}
</div>
</Box>
);
}
Loading
Loading