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
5 changes: 5 additions & 0 deletions .changeset/fuzzy-sandboxes-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge-ui': patch
---

Improve session timeline tooltips with sandbox tool details and grouped sub-agent tool calls.
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,28 @@ export function AgentSessionEventTimelineChart({
() => getSubAgentLanes({ subAgentSegments, threadSegments: durationSegments, minWidthMs: MARKER_PX * msPerPx }),
[durationSegments, msPerPx, subAgentSegments],
);
const subAgentToolCallGroups = useMemo(
() =>
subAgentLanes.flatMap(lane =>
groupOverlappingToolCalls(lane.segments.filter(segment => segment.type === 'tool_call'))
.filter(group => group.segments.length > 1)
.map(group => ({ group, lane: lane.lane, subAgentLabel: lane.track.description })),
),
[subAgentLanes],
);
const groupedSubAgentToolCallIds = useMemo(
() => new Set(subAgentToolCallGroups.flatMap(({ group }) => group.segments.map(segment => segment.id))),
[subAgentToolCallGroups],
);
const subAgentEventSegments = useMemo(
() =>
subAgentLanes.flatMap(lane =>
lane.segments
.filter(segment => !groupedSubAgentToolCallIds.has(segment.id))
.map(segment => ({ segment, lane: lane.lane })),
),
[groupedSubAgentToolCallIds, subAgentLanes],
);
const mainCandidates = useMemo(() => {
return [...durationSegments.filter(segment => segment.threadId === MAIN_THREAD_ID), ...mainSubAgents].sort(
(left, right) => left.startMs - right.startMs || left.endMs - right.endMs,
Expand Down Expand Up @@ -242,17 +264,28 @@ export function AgentSessionEventTimelineChart({
() => [
...turnBars.map((bar): TimelineHoverTarget => ({ type: TIMELINE_TYPE.turn, bar })),
...markerGroups.map((group): TimelineHoverTarget => ({ type: TIMELINE_TYPE.markerGroup, group })),
...subAgentLanes.flatMap(lane =>
lane.segments.map((segment): TimelineHoverTarget => ({ type: TIMELINE_TYPE.event, segment })),
),
...subAgentEventSegments.map(({ segment }): TimelineHoverTarget => ({ type: TIMELINE_TYPE.event, segment })),
...subAgentToolCallGroups.map(({ group, subAgentLabel }): TimelineHoverTarget => ({
type: TIMELINE_TYPE.toolCallGroup,
group,
subAgentLabel,
})),
...mainEventSegments.map((segment): TimelineHoverTarget | null => {
if (segment.type !== 'sub_agent') return { type: TIMELINE_TYPE.event, segment };
const group = subAgentGroups.find(candidate => candidate.barId === segment.id);
return group == null ? { type: TIMELINE_TYPE.event, segment } : { type: TIMELINE_TYPE.subAgentGroup, group };
}),
...toolCallGroups.map((group): TimelineHoverTarget => ({ type: TIMELINE_TYPE.toolCallGroup, group })),
],
[mainEventSegments, markerGroups, subAgentGroups, subAgentLanes, toolCallGroups, turnBars],
[
mainEventSegments,
markerGroups,
subAgentEventSegments,
subAgentGroups,
subAgentToolCallGroups,
toolCallGroups,
turnBars,
],
);

const barDataset = ({
Expand Down Expand Up @@ -319,19 +352,29 @@ export function AgentSessionEventTimelineChart({
order: 0,
} satisfies ChartDataset<'scatter', MarkerPoint[]>;
}),
...subAgentLanes.flatMap(lane =>
lane.segments.map(segment =>
barDataset({
label: `${getSessionEventLabel(segment.type)}: ${segment.title}`,
range: segment,
y: centers[eventRow + 1 + lane.lane] ?? 0,
color: getSessionEventColor(segment.type, isDark),
hover: getSessionEventHoverColor(segment.type, isDark),
thickness: 12,
order: eventOrder(segment.type),
inflateAmount: 0.5,
}),
),
...subAgentEventSegments.map(({ segment, lane }) =>
barDataset({
label: `${getSessionEventLabel(segment.type)}: ${segment.title}`,
range: segment,
y: centers[eventRow + 1 + lane] ?? 0,
color: getSessionEventColor(segment.type, isDark),
hover: getSessionEventHoverColor(segment.type, isDark),
thickness: 12,
order: eventOrder(segment.type),
inflateAmount: 0.5,
}),
),
...subAgentToolCallGroups.map(({ group, lane }) =>
barDataset({
label: 'Parallel tool calls',
range: group,
y: centers[eventRow + 1 + lane] ?? 0,
color: getSessionEventColor('tool_call', isDark),
hover: getSessionEventHoverColor('tool_call', isDark),
thickness: 12,
order: eventOrder('tool_call'),
inflateAmount: 0.5,
}),
),
...mainEventSegments.map(segment =>
barDataset({
Expand Down Expand Up @@ -366,7 +409,8 @@ export function AgentSessionEventTimelineChart({
mainEventSegments,
markerGroups,
markerRow,
subAgentLanes,
subAgentEventSegments,
subAgentToolCallGroups,
toolCallGroups,
turnBars,
turnFill,
Expand Down Expand Up @@ -512,7 +556,10 @@ export function AgentSessionEventTimelineChart({
}
/>
) : tooltipTarget?.type === TIMELINE_TYPE.toolCallGroup ? (
<SessionToolCallGroupTooltip group={tooltipTarget.group} />
<SessionToolCallGroupTooltip
group={tooltipTarget.group}
{...(tooltipTarget.subAgentLabel == null ? {} : { subAgentLabel: tooltipTarget.subAgentLabel })}
/>
) : tooltipTarget?.type === TIMELINE_TYPE.markerGroup ? (
<SessionMarkerGroupTooltip group={tooltipTarget.group} />
) : tooltipTarget?.type === TIMELINE_TYPE.subAgentGroup ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,23 @@ export function SessionTurnTooltip({
);
}

export function SessionToolCallGroupTooltip({ group }: { group: TimelineToolCallGroup }) {
export function SessionToolCallGroupTooltip({
group,
subAgentLabel,
}: {
group: TimelineToolCallGroup;
subAgentLabel?: string;
}) {
return (
<div className="max-h-72 w-80 max-w-full overflow-auto text-xs">
<div className="flex items-center justify-between gap-3">
{subAgentLabel != null ? (
<div className="border-b border-border py-1.5 text-text-secondary">{`Sub-Agent: ${subAgentLabel}`}</div>
) : null}
<div className="flex items-center justify-between gap-3 pt-1">
<span className="font-medium text-text-secondary">Tool calls</span>
<span className="tabular-nums text-text-secondary">{formatTimelineDuration(group.endMs - group.startMs)}</span>
</div>
<div className="mt-1.5 border-t border-border pt-1.5">
<div className={subAgentLabel == null ? 'mt-1.5 border-t border-border pt-1.5' : 'mt-1.5'}>
{group.segments.map(segment => (
<div key={segment.id} className="flex items-center justify-between gap-2 py-0.5">
<span className="min-w-0 truncate font-medium text-text-primary">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export type TimelineHoverTarget =
| { type: typeof TIMELINE_TYPE.turn; bar: TimelineTurnBar }
| { type: typeof TIMELINE_TYPE.event; segment: SessionEventTimelineSegment }
| { type: typeof TIMELINE_TYPE.markerGroup; group: TimelineMarkerGroup }
| { type: typeof TIMELINE_TYPE.toolCallGroup; group: TimelineToolCallGroup }
| { type: typeof TIMELINE_TYPE.toolCallGroup; group: TimelineToolCallGroup; subAgentLabel?: string }
| { type: typeof TIMELINE_TYPE.subAgentGroup; group: TimelineSubAgentGroup };

/** Stable identity used to avoid replacing tooltip state while hovering the same bar. */
Expand Down
3 changes: 2 additions & 1 deletion packages/trueforge-ui/src/utils/sessionTimelineEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ export function toolCallDescription(toolCall: Record<string, unknown>): string {
const argumentsJson = typeof fn?.arguments === 'string' ? fn.arguments : undefined;
if (SANDBOX_TOOL_NAMES.has(name) || name === 'code_sandbox') {
const intent = parseSandboxArgs(argumentsJson).intent?.trim();
return intent != null && intent.length > 0 ? intent : name;
const sandboxTool = `Sandbox: ${name}`;
return intent != null && intent.length > 0 ? `${sandboxTool} - ${intent}` : sandboxTool;
}
// Deferred MCP wrappers store the real tool in args; surface it like ToolCallContainer.
if (MCP_META_TOOLS.has(name)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,59 @@ describe('AgentSessionEventTimelineChart', () => {
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});

it('groups overlapping tool calls within a sub-agent lane', async () => {
const subAgentSegments: SessionEventTimelineSegment[] = [
...segments,
{
id: 'sub-agent',
type: 'sub_agent',
title: 'thread.created',
description: 'Researcher',
startMs: 100,
endMs: 900,
turnIndex: 0,
threadId: 'sub-agent-thread',
},
{
id: 'sub-tool-1',
type: 'tool_call',
title: 'tool.call',
description: 'search',
startMs: 200,
endMs: 600,
turnIndex: 0,
threadId: 'sub-agent-thread',
},
{
id: 'sub-tool-2',
type: 'tool_call',
title: 'tool.call',
description: 'fetch',
startMs: 300,
endMs: 700,
turnIndex: 0,
threadId: 'sub-agent-thread',
},
];
render(<AgentSessionEventTimelineChart turns={turns} segments={subAgentSegments} hiddenTypes={new Set()} />);

const datasets = Reflect.get(capturedData ?? {}, 'datasets');
if (!Array.isArray(datasets)) throw new Error('Expected chart datasets');
const groupedDatasetIndex = datasets.findIndex(dataset => Reflect.get(dataset, 'label') === 'Parallel tool calls');
expect(groupedDatasetIndex).toBeGreaterThanOrEqual(0);
expect(datasets.some(dataset => Reflect.get(dataset, 'label') === 'Tool call: search')).toBe(false);
expect(datasets.some(dataset => Reflect.get(dataset, 'label') === 'Tool call: fetch')).toBe(false);

const onHover = Reflect.get(capturedOptions ?? {}, 'onHover');
if (typeof onHover !== 'function') throw new Error('Expected Chart.js onHover callback');
act(() => onHover({ native: null }, [{ datasetIndex: groupedDatasetIndex }]));

expect(await screen.findByText('Sub-Agent: Researcher')).toBeInTheDocument();
expect(await screen.findByText('Tool calls')).toBeInTheDocument();
expect(screen.getByText('search').parentElement?.parentElement).not.toHaveClass('border-t');
expect(screen.getByText('fetch')).toBeInTheDocument();
});

it('summarizes every sub-agent represented by a shared bar', () => {
render(
<SessionSubAgentGroupTooltip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,24 @@ import { describe, expect, it } from 'vitest';
import { toolCallDescription } from '@/utils/sessionTimelineEvents.js';

describe('toolCallDescription', () => {
it('shows sandbox intent instead of the exec tool name', () => {
it('shows the sandbox tool name and intent', () => {
expect(
toolCallDescription({
function: {
name: 'exec',
arguments: JSON.stringify({ command: 'ls', intent: 'List workspace files' }),
},
}),
).toBe('List workspace files');
).toBe('Sandbox: exec - List workspace files');
expect(
toolCallDescription({
function: { name: 'sandbox_exec', arguments: '{"intent":"Generate a PDF"}' },
}),
).toBe('Generate a PDF');
).toBe('Sandbox: sandbox_exec - Generate a PDF');
});

it('falls back to the tool name when intent is missing', () => {
expect(toolCallDescription({ function: { name: 'exec', arguments: '{"command":"ls"}' } })).toBe('exec');
expect(toolCallDescription({ function: { name: 'exec', arguments: '{"command":"ls"}' } })).toBe('Sandbox: exec');
expect(toolCallDescription({ function: { name: 'search', arguments: '{"q":"x"}' } })).toBe('search');
});

Expand Down
Loading