From 42b11967b927dcf6882bd2a8831764a47c971e3c Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Fri, 23 Oct 2026 17:38:33 +0530 Subject: [PATCH 1/3] feat: enable scrolling for long tooltips and timeline charts --- .changeset/metric-tooltip-scroll.md | 5 + .changeset/timeline-chart-scroll.md | 5 + .../AgentSessionEventTimelineChart.tsx | 237 ++++++++++++------ .../AgentSessionMetricCharts.tsx | 45 +++- .../AgentSessionMetricsStrip.tsx | 2 +- .../src/utils/sessionEventTimelineChart.ts | 97 ++++++- .../AgentSessionEventTimelineChart.test.tsx | 42 ++++ .../atoms/AgentSessionMetricsStrip.test.tsx | 33 +++ .../test/utils/sessionEventTimeline.test.ts | 72 ++++++ 9 files changed, 447 insertions(+), 91 deletions(-) create mode 100644 .changeset/metric-tooltip-scroll.md create mode 100644 .changeset/timeline-chart-scroll.md diff --git a/.changeset/metric-tooltip-scroll.md b/.changeset/metric-tooltip-scroll.md new file mode 100644 index 000000000..27004dadb --- /dev/null +++ b/.changeset/metric-tooltip-scroll.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Let context and tool-call metric tooltips scroll when the list or a label is too long to fit. diff --git a/.changeset/timeline-chart-scroll.md b/.changeset/timeline-chart-scroll.md new file mode 100644 index 000000000..2e11a9aca --- /dev/null +++ b/.changeset/timeline-chart-scroll.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Let the session timeline chart scroll horizontally when a session has many turns. diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionEventTimelineChart.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionEventTimelineChart.tsx index a8b314b7f..fc1969948 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionEventTimelineChart.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionEventTimelineChart.tsx @@ -28,19 +28,22 @@ import { import { buildTimelineAxisTicks, formatTimelineAxisDuration, - getActiveTimelineMs, getSubAgentHoverGroups, getSubAgentLanes, + getTimelineAxisTickTarget, getTimelineHoverTargetId, getTimelineLayout, getTimelineRange, + getTimelineTurnStretch, groupCoincidentTimelineMarkers, groupOverlappingToolCalls, mergeOverlappingSubAgentSegments, TIMELINE_TYPE, - type TimelineGap, + timelineChartMs, type TimelineHoverTarget, type TimelineTurnBar, + type TimelineTurnRange, + type TimelineTurnStretch, } from '../../utils/sessionEventTimelineChart.js'; import { aggregateSessionTurnBand } from '../../utils/sessionTurnViews.js'; import { LightTooltip } from '../primitives/Tooltip.js'; @@ -57,6 +60,8 @@ import type { AgentSessionEventTimelineChartProps } from './types.js'; ChartJS.register(BarController, BarElement, LinearScale, PointElement, ScatterController, Tooltip); const MARKER_PX = 4; +/** Wide enough for a T999 label in the 10px turn font. */ +const MIN_TURN_BAR_PX = 32; const TURN_GAP_PX = 12; const END_PAD_PX = 12; const BAR_RADIUS_PX = 2; @@ -84,11 +89,51 @@ function eventOrder(type: SessionEventTimelineSegment['type']): number { return 1; } -function turnBarLabelPoint(element: object): { x: number; y: number } | null { +function chartMsForTurn({ + realMs, + turn, + stretch, + msPerPx, +}: { + realMs: number; + turn: TimelineTurnRange; + stretch: TimelineTurnStretch; + msPerPx: number; +}): number { + return timelineChartMs({ + realMs, + turnStartMs: turn.startMs, + turnEndMs: turn.endMs, + ordinal: turn.ordinal, + stretch, + msPerPx, + turnGapPx: TURN_GAP_PX, + }); +} + +function turnBarEndMs({ + turn, + stretch, + msPerPx, +}: { + turn: TimelineTurnRange; + stretch: TimelineTurnStretch; + msPerPx: number; +}): number { + // Covers zero-length turns too, whose stretch cannot come from scaling. + const extraPx = stretch.extraPx[turn.ordinal] ?? 0; + return ( + chartMsForTurn({ realMs: turn.startMs, turn, stretch, msPerPx }) + + (turn.endMs - turn.startMs) + + (extraPx + MARKER_PX) * msPerPx + ); +} + +function turnBarLabelPoint(element: object): { x: number; y: number; width: number } | null { if (!('x' in element) || !('y' in element) || !('base' in element)) return null; const { x, y, base } = element; if (typeof x !== 'number' || typeof y !== 'number' || typeof base !== 'number') return null; - return { x: (x + base) / 2, y }; + return { x: (x + base) / 2, y, width: Math.abs(x - base) }; } export function AgentSessionEventTimelineChart({ @@ -149,34 +194,51 @@ export function AgentSessionEventTimelineChart({ }, [refreshTooltipAnchor, tooltipTarget]); const segmentTotalMs = Math.max(0, ...segments.map(segment => segment.endMs)); - const activeTotalMs = Math.max(1, roundDurationMsToSecond(segmentTotalMs)); - const { turnStartsMs, turnOrdinals, turnRanges } = useMemo(() => getTimelineLayout(segments), [segments]); - const activeTurnStartsMs = useMemo( - () => turnStartsMs.filter((startMs, index) => index === 0 || startMs <= activeTotalMs), - [activeTotalMs, turnStartsMs], + // Layout uses the real end so the content exactly fills the pane; rounding only applies to axis labels. + const activeTotalMs = Math.max(1, segmentTotalMs); + const axisTotalMs = Math.max(1, roundDurationMsToSecond(segmentTotalMs)); + const { turnRanges } = useMemo(() => getTimelineLayout(segments), [segments]); + const gapCount = Math.max(0, turnRanges.length - 1); + // Long turns keep the pane's time scale. Short turns stretch on top of it and widen the chart. + const fitScaleWidthPx = Math.max(widthPx / 2, widthPx - TURN_GAP_PX * gapCount - END_PAD_PX); + const msPerPx = activeTotalMs / Math.max(1, fitScaleWidthPx); + const orderedRanges = useMemo( + () => [...turnRanges].sort((left, right) => left.ordinal - right.ordinal), + [turnRanges], ); - const gapCount = Math.max(0, activeTurnStartsMs.length - 1); - const scaleWidthPx = Math.max(widthPx / 2, widthPx - TURN_GAP_PX * gapCount - END_PAD_PX); - const msPerPx = activeTotalMs / Math.max(1, scaleWidthPx); - const turnGapMs = TURN_GAP_PX * msPerPx; - const totalMs = activeTotalMs + gapCount * turnGapMs; - const timelineMaxMs = totalMs + END_PAD_PX * msPerPx; - const timelineGaps = useMemo( + const stretch = useMemo( () => - activeTurnStartsMs - .slice(1) - .map((startMs, index) => ({ startMs: startMs + index * turnGapMs, endMs: startMs + (index + 1) * turnGapMs })), - [activeTurnStartsMs, turnGapMs], + widthPx > 0 + ? getTimelineTurnStretch({ + durationsMs: orderedRanges.map(range => Math.max(0, range.endMs - range.startMs)), + msPerPx, + minTurnBarPx: MIN_TURN_BAR_PX, + markerPx: MARKER_PX, + }) + : { + extraPx: orderedRanges.map(() => 0), + scale: orderedRanges.map(() => 1), + leadingExtraPx: orderedRanges.map(() => 0), + }, + [msPerPx, orderedRanges, widthPx], ); + const turnsByIndex = useMemo(() => new Map(orderedRanges.map(range => [range.turnIndex, range])), [orderedRanges]); const visibleSegments = useMemo( () => segments.flatMap(segment => { if (hiddenTypes.has(segment.type)) return []; - const shiftMs = (turnOrdinals.get(segment.turnIndex) ?? 0) * turnGapMs; - return [{ ...segment, startMs: segment.startMs + shiftMs, endMs: segment.endMs + shiftMs }]; + const turn = turnsByIndex.get(segment.turnIndex); + if (turn == null) return [{ ...segment }]; + return [ + { + ...segment, + startMs: chartMsForTurn({ realMs: segment.startMs, turn, stretch, msPerPx }), + endMs: chartMsForTurn({ realMs: segment.endMs, turn, stretch, msPerPx }), + }, + ]; }), - [hiddenTypes, segments, turnGapMs, turnOrdinals], + [hiddenTypes, msPerPx, segments, stretch, turnsByIndex], ); const markerGroups = useMemo( () => groupCoincidentTimelineMarkers(visibleSegments, { toleranceMs: MARKER_PX * msPerPx }), @@ -185,17 +247,22 @@ export function AgentSessionEventTimelineChart({ const durationSegments = useMemo(() => visibleSegments.filter(segment => !segment.isMarker), [visibleSegments]); const turnBars = useMemo( () => - turnRanges.length < 2 + orderedRanges.length < 2 ? [] - : turnRanges.map(range => ({ + : orderedRanges.map(range => ({ ...range, id: `turn-${range.turnIndex}`, durationMs: range.endMs - range.startMs, - startMs: range.startMs + range.ordinal * turnGapMs, - endMs: range.endMs + range.ordinal * turnGapMs + MARKER_PX * msPerPx, + startMs: chartMsForTurn({ realMs: range.startMs, turn: range, stretch, msPerPx }), + endMs: turnBarEndMs({ turn: range, stretch, msPerPx }), })), - [msPerPx, turnGapMs, turnRanges], + [msPerPx, orderedRanges, stretch], ); + // Unstretched content fills the pane exactly, so only short-turn stretch can overflow it. + const totalExtraPx = stretch.extraPx.reduce((sum, px) => sum + px, 0); + const scrolls = totalExtraPx > 0; + const chartWidthPx = widthPx + Math.ceil(totalExtraPx); + const timelineMaxMs = Math.max(1, chartWidthPx) * msPerPx; const subAgentSegments = useMemo( () => durationSegments.filter(segment => segment.type === 'sub_agent'), [durationSegments], @@ -436,7 +503,10 @@ export function AgentSessionEventTimelineChart({ const element = chart.getDatasetMeta(index).data[0]; if (element == null) return; const point = turnBarLabelPoint(element); - if (point) ctx.fillText(`T${bar.ordinal + 1}`, point.x, point.y); + if (point == null) return; + const label = `T${bar.ordinal + 1}`; + if (ctx.measureText(label).width > point.width) return; + ctx.fillText(label, point.x, point.y); }); ctx.restore(); }, @@ -454,8 +524,9 @@ export function AgentSessionEventTimelineChart({ [], ); - const chartOptions = useMemo>( - () => ({ + const chartOptions = useMemo>(() => { + const tickLabels = new Map(); + return { indexAxis: 'y', animation: false, responsive: true, @@ -497,20 +568,34 @@ export function AgentSessionEventTimelineChart({ grid: { color: grid }, border: { color: grid }, afterBuildTicks: scale => { - scale.ticks = buildTimelineAxisTicks({ activeTotalMs, turnStartsMs: activeTurnStartsMs, turnGapMs }); + tickLabels.clear(); + const realTicks = buildTimelineAxisTicks({ + activeTotalMs: axisTotalMs, + turnStartsMs: [], + turnGapMs: 0, + ...(chartWidthPx > 0 ? { chartWidthPx } : {}), + }); + scale.ticks = realTicks.map(tick => { + // A rounded-up end label still sits on the real end. + const realMs = Math.min(tick.value, activeTotalMs); + let turn = orderedRanges[0]; + if (turn != null) { + for (const range of orderedRanges) { + if (range.startMs <= realMs) turn = range; + } + } + const value = turn == null ? realMs : chartMsForTurn({ realMs, turn, stretch, msPerPx }); + tickLabels.set(value, formatTimelineAxisDuration(tick.value)); + return { value }; + }); }, ticks: { color: axis, align: 'inner', autoSkip: false, includeBounds: false, - maxTicksLimit: 9, - callback: value => { - const timelineMs = Number(value); - return timelineMs <= totalMs - ? formatTimelineAxisDuration(getActiveTimelineMs(timelineMs, timelineGaps)) - : ''; - }, + maxTicksLimit: Math.max(9, getTimelineAxisTickTarget(chartWidthPx)), + callback: value => tickLabels.get(Number(value)) ?? '', }, }, y: { @@ -523,22 +608,22 @@ export function AgentSessionEventTimelineChart({ grid: { display: false }, }, }, - }), - [ - activeTotalMs, - activeTurnStartsMs, - axis, - band, - chartTargets, - grid, - onSelectTurn, - refreshTooltipAnchor, - timelineGaps, - timelineMaxMs, - totalMs, - turnGapMs, - ], - ); + }; + }, [ + activeTotalMs, + axis, + axisTotalMs, + band, + chartWidthPx, + chartTargets, + grid, + msPerPx, + onSelectTurn, + orderedRanges, + refreshTooltipAnchor, + stretch, + timelineMaxMs, + ]); const tooltipTurnNumber = tooltipTarget?.type === TIMELINE_TYPE.turn ? tooltipTarget.bar.ordinal + 1 : undefined; const tooltipTurn = tooltipTurnNumber == null ? undefined : aggregateSessionTurnBand(turns, tooltipTurnNumber); @@ -572,26 +657,28 @@ export function AgentSessionEventTimelineChart({ ) : null; return ( -
- -
- , string> - type="bar" - data={chartData} - options={chartOptions} - plugins={[turnLabelPlugin, overheadPlugin]} - /> -
-
+
+
+ +
+ , string> + type="bar" + data={chartData} + options={chartOptions} + plugins={[turnLabelPlugin, overheadPlugin]} + /> +
+
+
); } diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionMetricCharts.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionMetricCharts.tsx index dd369ea11..1294eea8a 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionMetricCharts.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionMetricCharts.tsx @@ -84,25 +84,46 @@ export function StackedProportionBar({ export function HorizontalBarRows({ data, formatValue, + scrollX = false, }: { data: SessionMetricBarDatum[]; formatValue: (value: number) => string; + /** Context labels can be wider than the popup. Tool-call rows stay truncated and scroll vertically. */ + scrollX?: boolean; }) { const maxValue = Math.max(0, ...data.map(item => item.value)) || 1; return ( -
- {data.map(item => ( -
- {item.label} -
-
+
+
+ {data.map(item => ( +
+ {scrollX ? ( + {item.label} + ) : ( + + {item.label} + + )} +
+
+
+ {formatValue(item.value)}
- {formatValue(item.value)} -
- ))} + ))} +
); } diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionMetricsStrip.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionMetricsStrip.tsx index 6fcc1c52e..fe07e2ce2 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionMetricsStrip.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionMetricsStrip.tsx @@ -154,7 +154,7 @@ export function AgentSessionMetricsStrip({ metrics }: AgentSessionMetricsStripPr tooltip={ metrics.contextByTurn.length > 1 && metrics.contextTokens > 0 ? ( - + ) : null } diff --git a/packages/trueforge-ui/src/utils/sessionEventTimelineChart.ts b/packages/trueforge-ui/src/utils/sessionEventTimelineChart.ts index 9dd1a1cf0..007a46141 100644 --- a/packages/trueforge-ui/src/utils/sessionEventTimelineChart.ts +++ b/packages/trueforge-ui/src/utils/sessionEventTimelineChart.ts @@ -132,9 +132,98 @@ export function formatTimelineAxisDuration(durationMs: number): string { return `${Number((durationMs / unit.divisorMs).toFixed(2))}${unit.suffix}`; } -function getCleanAxisStepMs(totalMs: number): number { +const DEFAULT_AXIS_TICK_TARGET = 8; +const AXIS_TICK_PX = 120; + +/** Width of the drawable chart. `0` means the scrollport has not been measured yet. */ +export function getTimelineChartWidthPx({ + viewportWidthPx, + contentPx, +}: { + viewportWidthPx: number; + contentPx: number; +}): number { + if (viewportWidthPx <= 0) return 0; + // Ignore subpixel overflow so a chart that already fits does not grow a scrollbar. + if (contentPx <= viewportWidthPx + 1) return viewportWidthPx; + return Math.ceil(contentPx); +} + +export type TimelineTurnStretch = { + /** Pixels added so the turn bar reaches the minimum. Indexed by ordinal. */ + extraPx: number[]; + /** Stretches offsets inside the turn. `1` when the bar is already wide enough. */ + scale: number[]; + /** Sum of `extraPx` before this ordinal. */ + leadingExtraPx: number[]; +}; + +/** + * Give every turn at least `minTurnBarPx` without changing how long turns share the pane. + * Short turns stretch internally; later turns shift right by that extra width. + */ +export function getTimelineTurnStretch({ + durationsMs, + msPerPx, + minTurnBarPx, + markerPx, +}: { + durationsMs: number[]; + msPerPx: number; + minTurnBarPx: number; + markerPx: number; +}): TimelineTurnStretch { + const extraPx: number[] = []; + const scale: number[] = []; + const leadingExtraPx: number[] = []; + let leading = 0; + for (const durationMs of durationsMs) { + leadingExtraPx.push(leading); + const durationPx = msPerPx > 0 ? Math.max(0, durationMs) / msPerPx : 0; + const extra = Math.max(0, minTurnBarPx - (durationPx + markerPx)); + extraPx.push(extra); + scale.push(durationPx > 0 ? (durationPx + extra) / durationPx : 1); + leading += extra; + } + return { extraPx, scale, leadingExtraPx }; +} + +/** Map a real timestamp onto the chart axis, including turn gaps and short-turn stretch. */ +export function timelineChartMs({ + realMs, + turnStartMs, + turnEndMs, + ordinal, + stretch, + msPerPx, + turnGapPx, +}: { + realMs: number; + turnStartMs: number; + turnEndMs: number; + ordinal: number; + stretch: TimelineTurnStretch; + msPerPx: number; + turnGapPx: number; +}): number { + const scaleFactor = stretch.scale[ordinal] ?? 1; + const shiftMs = (stretch.leadingExtraPx[ordinal] ?? 0) * msPerPx + ordinal * turnGapPx * msPerPx; + if (realMs < turnStartMs) return realMs + shiftMs; + if (realMs > turnEndMs) { + return turnStartMs + (turnEndMs - turnStartMs) * scaleFactor + shiftMs + (realMs - turnEndMs); + } + return turnStartMs + (realMs - turnStartMs) * scaleFactor + shiftMs; +} + +/** About one label per 120px, and never fewer than the default eight. */ +export function getTimelineAxisTickTarget(chartWidthPx?: number): number { + if (chartWidthPx == null || chartWidthPx <= 0) return DEFAULT_AXIS_TICK_TARGET; + return Math.max(DEFAULT_AXIS_TICK_TARGET, Math.round(chartWidthPx / AXIS_TICK_PX)); +} + +function getCleanAxisStepMs(totalMs: number, tickTarget: number): number { const { divisorMs } = timelineAxisUnit(totalMs); - const target = totalMs / divisorMs / 8; + const target = totalMs / divisorMs / tickTarget; if (divisorMs >= 60_000 && target < 1) return Math.max(0.25, Math.ceil(target * 4) / 4) * divisorMs; const integerTarget = Math.max(1, target); const magnitude = 10 ** Math.floor(Math.log10(integerTarget)); @@ -148,12 +237,14 @@ export function buildTimelineAxisTicks({ activeTotalMs, turnStartsMs, turnGapMs, + chartWidthPx, }: { activeTotalMs: number; turnStartsMs: number[]; turnGapMs: number; + chartWidthPx?: number; }): Array<{ value: number }> { - const stepMs = getCleanAxisStepMs(activeTotalMs); + const stepMs = getCleanAxisStepMs(activeTotalMs, getTimelineAxisTickTarget(chartWidthPx)); const activeTicks: number[] = []; for (let activeMs = 0; activeMs < activeTotalMs; activeMs += stepMs) activeTicks.push(activeMs); const lastRegularTick = activeTicks.at(-1); diff --git a/packages/trueforge-ui/test/atoms/AgentSessionEventTimelineChart.test.tsx b/packages/trueforge-ui/test/atoms/AgentSessionEventTimelineChart.test.tsx index fa2272da7..1cc5d9740 100644 --- a/packages/trueforge-ui/test/atoms/AgentSessionEventTimelineChart.test.tsx +++ b/packages/trueforge-ui/test/atoms/AgentSessionEventTimelineChart.test.tsx @@ -154,6 +154,48 @@ describe('AgentSessionEventTimelineChart', () => { expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); + it('grows wider than the pane and scrolls when short turns would otherwise collapse', async () => { + const clientWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(400); + const manyTurns: SessionEventTimelineSegment[] = [ + ...Array.from({ length: 8 }, (_, turnIndex) => ({ + id: `turn-${turnIndex}-model`, + type: 'model' as const, + title: 'model.message', + description: 'response', + startMs: turnIndex * 1_000, + endMs: turnIndex * 1_000 + 1_000, + turnIndex, + threadId: 'main', + })), + { + id: 'turn-long-model', + type: 'model', + title: 'model.message', + description: 'response', + startMs: 8_000, + endMs: 108_000, + turnIndex: 8, + threadId: 'main', + }, + ]; + + try { + const { container } = render( + , + ); + const wrapper = container.querySelector('[data-slot="agent-session-event-timeline-chart"]'); + if (wrapper == null) throw new Error('Expected timeline chart wrapper'); + expect(wrapper).toHaveClass('overflow-x-auto'); + await waitFor(() => { + const width = wrapper.firstElementChild?.getAttribute('style') ?? ''; + const pixels = Number(/width:\s*(\d+(?:\.\d+)?)px/.exec(width)?.[1]); + expect(pixels).toBeGreaterThan(400); + }); + } finally { + clientWidth.mockRestore(); + } + }); + it('groups overlapping tool calls within a sub-agent lane', async () => { const subAgentSegments: SessionEventTimelineSegment[] = [ ...segments, diff --git a/packages/trueforge-ui/test/atoms/AgentSessionMetricsStrip.test.tsx b/packages/trueforge-ui/test/atoms/AgentSessionMetricsStrip.test.tsx index c1a27cb3e..4a40feca2 100644 --- a/packages/trueforge-ui/test/atoms/AgentSessionMetricsStrip.test.tsx +++ b/packages/trueforge-ui/test/atoms/AgentSessionMetricsStrip.test.tsx @@ -84,6 +84,39 @@ describe('AgentSessionMetricsStrip', () => { }, ); + it('scrolls long context and tool-call names inside the tooltip', () => { + render( + , + ); + + const contextTile = document.querySelector('[data-slot="session-metric-context"]'); + if (contextTile == null) throw new Error('Expected context metric tile'); + fireEvent.mouseEnter(contextTile); + const contextLabel = screen.getByText('turn-with-a-very-long-context-label'); + expect(contextLabel).not.toHaveClass('truncate'); + expect(contextLabel.closest('.max-h-72')).toHaveClass('overflow-auto', 'pr-3'); + fireEvent.mouseLeave(contextTile); + + const toolTile = document.querySelector('[data-slot="session-metric-tool-calls"]'); + if (toolTile == null) throw new Error('Expected tool-call metric tile'); + fireEvent.mouseEnter(toolTile); + const toolLabel = screen.getByText('sandbox_exec_with_a_very_long_tool_name'); + expect(toolLabel).toHaveClass('truncate'); + expect(toolLabel).toHaveAttribute('title', 'sandbox_exec_with_a_very_long_tool_name'); + const toolScroller = toolLabel.closest('.max-h-72'); + expect(toolScroller).toHaveClass('overflow-y-auto', 'overflow-x-hidden', 'pr-3'); + expect(toolScroller).not.toHaveClass('overflow-auto'); + }); + it('shows lineage / session total with explanation when session total exceeds lineage', () => { render(); const turnsTile = document.querySelector('[data-slot="session-metric-turns"]'); diff --git a/packages/trueforge-ui/test/utils/sessionEventTimeline.test.ts b/packages/trueforge-ui/test/utils/sessionEventTimeline.test.ts index b671670bc..50fea7fc7 100644 --- a/packages/trueforge-ui/test/utils/sessionEventTimeline.test.ts +++ b/packages/trueforge-ui/test/utils/sessionEventTimeline.test.ts @@ -12,9 +12,12 @@ import { getActiveTimelineMs, getSubAgentHoverGroups, getSubAgentLanes, + getTimelineChartWidthPx, + getTimelineTurnStretch, groupCoincidentTimelineMarkers, groupOverlappingToolCalls, mergeOverlappingSubAgentSegments, + timelineChartMs, } from '@/utils/sessionEventTimelineChart.js'; import { buildSessionTurnViews } from '@/utils/sessionTurnViews.js'; @@ -633,6 +636,75 @@ describe('sessionEventTimelineChart helpers', () => { ); }); + it('keeps the default tick density until the chart is wider than about 120px per label', () => { + const narrow = buildTimelineAxisTicks({ activeTotalMs: 960_000, turnStartsMs: [0], turnGapMs: 0 }); + assert.deepEqual( + buildTimelineAxisTicks({ activeTotalMs: 960_000, turnStartsMs: [0], turnGapMs: 0, chartWidthPx: 400 }), + narrow, + ); + const wide = buildTimelineAxisTicks({ + activeTotalMs: 960_000, + turnStartsMs: [0], + turnGapMs: 0, + chartWidthPx: 2_400, + }); + assert.ok(wide.length > narrow.length); + }); + + it('keeps a chart that fits inside the pane and grows one that does not', () => { + assert.equal(getTimelineChartWidthPx({ viewportWidthPx: 0, contentPx: 800 }), 0); + assert.equal(getTimelineChartWidthPx({ viewportWidthPx: 400, contentPx: 440 }), 440); + assert.equal(getTimelineChartWidthPx({ viewportWidthPx: 2_000, contentPx: 88 }), 2_000); + }); + + it('stretches a short turn to the minimum width and shifts the next turn', () => { + const msPerPx = 100; + const stretch = getTimelineTurnStretch({ + durationsMs: [500, 10_000], + msPerPx, + minTurnBarPx: 32, + markerPx: 4, + }); + assert.deepEqual(stretch.extraPx, [23, 0]); + assert.equal(stretch.scale[1], 1); + assert.equal( + timelineChartMs({ + realMs: 500, + turnStartMs: 0, + turnEndMs: 500, + ordinal: 0, + stretch, + msPerPx, + turnGapPx: 12, + }), + 2_800, + ); + assert.equal( + timelineChartMs({ + realMs: 500, + turnStartMs: 500, + turnEndMs: 10_500, + ordinal: 1, + stretch, + msPerPx, + turnGapPx: 12, + }), + 4_000, + ); + assert.equal( + timelineChartMs({ + realMs: 1_000, + turnStartMs: 0, + turnEndMs: 1_000, + ordinal: 1, + stretch: { extraPx: [0, 0], scale: [1, 1], leadingExtraPx: [0, 0] }, + msPerPx, + turnGapPx: 12, + }), + 2_200, + ); + }); + it('compresses inter-turn gaps onto a contiguous axis', () => { const compressed = compressInterTurnGaps([ { From 7ff3764d133309470b6659e81f6ea44a53b7bd62 Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Fri, 23 Oct 2026 17:57:44 +0530 Subject: [PATCH 2/3] feat: enhance tooltip scrolling and improve timeline chart layout --- .changeset/metric-tooltip-scroll.md | 2 +- .../AgentSessionEventTimelineChart.tsx | 10 +++-- .../AgentSessionMetricCharts.tsx | 28 ++++++++------ .../src/utils/sessionEventTimelineChart.ts | 18 +-------- .../AgentSessionEventTimelineChart.test.tsx | 38 +++++++++++++++++++ .../atoms/AgentSessionMetricsStrip.test.tsx | 5 ++- .../test/utils/sessionEventTimeline.test.ts | 7 ---- 7 files changed, 67 insertions(+), 41 deletions(-) diff --git a/.changeset/metric-tooltip-scroll.md b/.changeset/metric-tooltip-scroll.md index 27004dadb..fca42cde8 100644 --- a/.changeset/metric-tooltip-scroll.md +++ b/.changeset/metric-tooltip-scroll.md @@ -2,4 +2,4 @@ '@truefoundry/trueforge-ui': patch --- -Let context and tool-call metric tooltips scroll when the list or a label is too long to fit. +Let context, cost-per-turn, and tool-call metric tooltips scroll when the list or a label is too long to fit. diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionEventTimelineChart.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionEventTimelineChart.tsx index fc1969948..c70b2d8af 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionEventTimelineChart.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionEventTimelineChart.tsx @@ -200,7 +200,8 @@ export function AgentSessionEventTimelineChart({ const { turnRanges } = useMemo(() => getTimelineLayout(segments), [segments]); const gapCount = Math.max(0, turnRanges.length - 1); // Long turns keep the pane's time scale. Short turns stretch on top of it and widen the chart. - const fitScaleWidthPx = Math.max(widthPx / 2, widthPx - TURN_GAP_PX * gapCount - END_PAD_PX); + const unclampedScaleWidthPx = widthPx - TURN_GAP_PX * gapCount - END_PAD_PX; + const fitScaleWidthPx = Math.max(widthPx / 2, unclampedScaleWidthPx); const msPerPx = activeTotalMs / Math.max(1, fitScaleWidthPx); const orderedRanges = useMemo( () => [...turnRanges].sort((left, right) => left.ordinal - right.ordinal), @@ -258,8 +259,11 @@ export function AgentSessionEventTimelineChart({ })), [msPerPx, orderedRanges, stretch], ); - // Unstretched content fills the pane exactly, so only short-turn stretch can overflow it. - const totalExtraPx = stretch.extraPx.reduce((sum, px) => sum + px, 0); + // Unstretched content fills the pane exactly, unless the half-pane floor above clamped the + // scale wider than the pane allows for this many gaps; that shortfall must widen the chart + // too, or the last turns draw past the axis and clip even though the pane says it scrolls. + const floorDeficitPx = widthPx > 0 ? Math.max(0, fitScaleWidthPx - unclampedScaleWidthPx) : 0; + const totalExtraPx = stretch.extraPx.reduce((sum, px) => sum + px, 0) + floorDeficitPx; const scrolls = totalExtraPx > 0; const chartWidthPx = widthPx + Math.ceil(totalExtraPx); const timelineMaxMs = Math.max(1, chartWidthPx) * msPerPx; diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionMetricCharts.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionMetricCharts.tsx index 1294eea8a..39a76d429 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionMetricCharts.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionMetricCharts.tsx @@ -8,19 +8,23 @@ import { formatCostUsd } from '../../utils/sessionDisplayFormat.js'; export function CostBreakdownRows({ data }: { data: SessionMetricBarDatum[] }) { const maxValue = Math.max(0, ...data.map(item => item.value)) || 1; return ( -
- {data.map(item => ( -
- {item.label} -
-
+
+
+ {data.map(item => ( +
+ + {item.label} + +
+
+
+ {formatCostUsd(item.value)}
- {formatCostUsd(item.value)} -
- ))} + ))} +
); } diff --git a/packages/trueforge-ui/src/utils/sessionEventTimelineChart.ts b/packages/trueforge-ui/src/utils/sessionEventTimelineChart.ts index 007a46141..2cbfed469 100644 --- a/packages/trueforge-ui/src/utils/sessionEventTimelineChart.ts +++ b/packages/trueforge-ui/src/utils/sessionEventTimelineChart.ts @@ -135,20 +135,6 @@ export function formatTimelineAxisDuration(durationMs: number): string { const DEFAULT_AXIS_TICK_TARGET = 8; const AXIS_TICK_PX = 120; -/** Width of the drawable chart. `0` means the scrollport has not been measured yet. */ -export function getTimelineChartWidthPx({ - viewportWidthPx, - contentPx, -}: { - viewportWidthPx: number; - contentPx: number; -}): number { - if (viewportWidthPx <= 0) return 0; - // Ignore subpixel overflow so a chart that already fits does not grow a scrollbar. - if (contentPx <= viewportWidthPx + 1) return viewportWidthPx; - return Math.ceil(contentPx); -} - export type TimelineTurnStretch = { /** Pixels added so the turn bar reaches the minimum. Indexed by ordinal. */ extraPx: number[]; @@ -221,7 +207,7 @@ export function getTimelineAxisTickTarget(chartWidthPx?: number): number { return Math.max(DEFAULT_AXIS_TICK_TARGET, Math.round(chartWidthPx / AXIS_TICK_PX)); } -function getCleanAxisStepMs(totalMs: number, tickTarget: number): number { +function getCleanAxisStepMs({ totalMs, tickTarget }: { totalMs: number; tickTarget: number }): number { const { divisorMs } = timelineAxisUnit(totalMs); const target = totalMs / divisorMs / tickTarget; if (divisorMs >= 60_000 && target < 1) return Math.max(0.25, Math.ceil(target * 4) / 4) * divisorMs; @@ -244,7 +230,7 @@ export function buildTimelineAxisTicks({ turnGapMs: number; chartWidthPx?: number; }): Array<{ value: number }> { - const stepMs = getCleanAxisStepMs(activeTotalMs, getTimelineAxisTickTarget(chartWidthPx)); + const stepMs = getCleanAxisStepMs({ totalMs: activeTotalMs, tickTarget: getTimelineAxisTickTarget(chartWidthPx) }); const activeTicks: number[] = []; for (let activeMs = 0; activeMs < activeTotalMs; activeMs += stepMs) activeTicks.push(activeMs); const lastRegularTick = activeTicks.at(-1); diff --git a/packages/trueforge-ui/test/atoms/AgentSessionEventTimelineChart.test.tsx b/packages/trueforge-ui/test/atoms/AgentSessionEventTimelineChart.test.tsx index 1cc5d9740..18f49ecf5 100644 --- a/packages/trueforge-ui/test/atoms/AgentSessionEventTimelineChart.test.tsx +++ b/packages/trueforge-ui/test/atoms/AgentSessionEventTimelineChart.test.tsx @@ -196,6 +196,44 @@ describe('AgentSessionEventTimelineChart', () => { } }); + it('keeps every turn within the drawn axis even when the gap floor forces extra room', () => { + // 100px pane, 6 turns -> 5 gaps. Unclamped scale (100 - 5*12 - 12 = 28) is below the + // half-pane floor (50), so the floor itself demands 22px more than the pane has. + const clientWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100); + const sixTurns: SessionEventTimelineSegment[] = Array.from({ length: 6 }, (_, turnIndex) => ({ + id: `turn-${turnIndex}-model`, + type: 'model' as const, + title: 'model.message', + description: 'response', + startMs: turnIndex * 1_000, + endMs: turnIndex * 1_000 + 1_000, + turnIndex, + threadId: 'main', + })); + + try { + render(); + const datasets = Reflect.get(capturedData ?? {}, 'datasets'); + if (!Array.isArray(datasets)) throw new Error('Expected chart datasets'); + const turnEndsMs = datasets.flatMap(dataset => { + const label = Reflect.get(dataset, 'label'); + if (typeof label !== 'string' || !label.startsWith('Turn ')) return []; + const data = Reflect.get(dataset, 'data'); + const point = Array.isArray(data) ? data[0] : undefined; + const range = typeof point === 'object' && point != null ? Reflect.get(point, 'x') : undefined; + return Array.isArray(range) ? [Number(range[1])] : []; + }); + expect(turnEndsMs).toHaveLength(6); + + const scales = Reflect.get(capturedOptions ?? {}, 'scales'); + const xScale = typeof scales === 'object' && scales != null ? Reflect.get(scales, 'x') : undefined; + const axisMaxMs = typeof xScale === 'object' && xScale != null ? Number(Reflect.get(xScale, 'max')) : NaN; + expect(axisMaxMs).toBeGreaterThanOrEqual(Math.max(...turnEndsMs)); + } finally { + clientWidth.mockRestore(); + } + }); + it('groups overlapping tool calls within a sub-agent lane', async () => { const subAgentSegments: SessionEventTimelineSegment[] = [ ...segments, diff --git a/packages/trueforge-ui/test/atoms/AgentSessionMetricsStrip.test.tsx b/packages/trueforge-ui/test/atoms/AgentSessionMetricsStrip.test.tsx index 4a40feca2..9ae63353e 100644 --- a/packages/trueforge-ui/test/atoms/AgentSessionMetricsStrip.test.tsx +++ b/packages/trueforge-ui/test/atoms/AgentSessionMetricsStrip.test.tsx @@ -153,7 +153,8 @@ describe('CostBreakdownRows', () => { />, ); - expect(screen.getByText('T1')).toBeInTheDocument(); + expect(screen.getByText('T1')).toHaveAttribute('title', 'T1'); + expect(screen.getByText('T1').closest('.max-h-72')).toHaveClass('overflow-y-auto', 'overflow-x-hidden', 'pr-3'); expect(screen.getByText('T2')).toBeInTheDocument(); expect(screen.getByText('$2.0000')).toBeInTheDocument(); expect(screen.getByText('$0.6000')).toBeInTheDocument(); @@ -161,6 +162,6 @@ describe('CostBreakdownRows', () => { it('renders nothing for an empty dataset', () => { const { container } = render(); - expect(container.firstChild).toBeEmptyDOMElement(); + expect(container.querySelector('.grid')).toBeEmptyDOMElement(); }); }); diff --git a/packages/trueforge-ui/test/utils/sessionEventTimeline.test.ts b/packages/trueforge-ui/test/utils/sessionEventTimeline.test.ts index 50fea7fc7..568fbe562 100644 --- a/packages/trueforge-ui/test/utils/sessionEventTimeline.test.ts +++ b/packages/trueforge-ui/test/utils/sessionEventTimeline.test.ts @@ -12,7 +12,6 @@ import { getActiveTimelineMs, getSubAgentHoverGroups, getSubAgentLanes, - getTimelineChartWidthPx, getTimelineTurnStretch, groupCoincidentTimelineMarkers, groupOverlappingToolCalls, @@ -651,12 +650,6 @@ describe('sessionEventTimelineChart helpers', () => { assert.ok(wide.length > narrow.length); }); - it('keeps a chart that fits inside the pane and grows one that does not', () => { - assert.equal(getTimelineChartWidthPx({ viewportWidthPx: 0, contentPx: 800 }), 0); - assert.equal(getTimelineChartWidthPx({ viewportWidthPx: 400, contentPx: 440 }), 440); - assert.equal(getTimelineChartWidthPx({ viewportWidthPx: 2_000, contentPx: 88 }), 2_000); - }); - it('stretches a short turn to the minimum width and shifts the next turn', () => { const msPerPx = 100; const stretch = getTimelineTurnStretch({ From ede5bde53e13db48ff7e495f775e832a649739b5 Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Fri, 23 Oct 2026 18:12:59 +0530 Subject: [PATCH 3/3] feat: enhance tooltip scrolling and improve timeline chart layout --- .../src/atoms/agent-details/AgentSessionEventTimelineChart.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionEventTimelineChart.tsx b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionEventTimelineChart.tsx index c70b2d8af..0e1b6f694 100644 --- a/packages/trueforge-ui/src/atoms/agent-details/AgentSessionEventTimelineChart.tsx +++ b/packages/trueforge-ui/src/atoms/agent-details/AgentSessionEventTimelineChart.tsx @@ -40,6 +40,7 @@ import { mergeOverlappingSubAgentSegments, TIMELINE_TYPE, timelineChartMs, + type TimelineGap, type TimelineHoverTarget, type TimelineTurnBar, type TimelineTurnRange,