feat: add rich run timeline UI with collapsible cards and jump-to-message

- Add RunTimeline component with vertical event timeline, collapsible run
  cards, per-kind icons, status animations, and agent lane badges
- Add runTimelineFormatting helpers for timestamps, durations, labels,
  content truncation, and consecutive thinking-event collapsing
- Integrate Timeline section into ActivityPanel between Agents and Tools
- Wire jump-to-message in App.tsx using DOM data-message-id attributes
  on ChatPane messages with smooth scroll and temporary highlight ring
- Add comprehensive unit tests for all formatting helpers
- Update HANDOVER.md to document completed frontend implementation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-24 00:17:14 +01:00
co-authored by Copilot
parent b742941559
commit 0d3f1cbd50
7 changed files with 640 additions and 5 deletions
+24 -2
View File
@@ -197,9 +197,31 @@ Suggested UI plan:
- Duplicate sessions intentionally start with empty run history. If product wants copied traces later, that needs an explicit decision.
- Live `run-updated` events currently send the full run snapshot each time. That keeps the reducer simple; optimize later only if payload size becomes a problem.
## Frontend implementation (completed)
The UI has been implemented with the following files:
- `src/renderer/lib/runTimelineFormatting.ts` — formatting helpers for timestamps, durations, event labels, and a thinking-event collapsing algorithm
- `src/renderer/components/RunTimeline.tsx` — the main timeline UI component with collapsible run cards, vertical timeline connector lines, event icons, and jump-to-message support
- `src/renderer/components/ActivityPanel.tsx` — updated to include a Timeline section between Agents and Tools
- `src/renderer/App.tsx` — wired `onJumpToMessage` callback that scrolls to the target message with a temporary highlight ring
- `src/renderer/components/ChatPane.tsx` — added `data-message-id` attributes on message elements for DOM-based scroll targeting
- `tests/renderer/runTimelineFormatting.test.ts` — unit tests for all formatting helpers
### UI features
- **Collapsible run cards** — newest first, auto-expanded for the latest run, with pattern name and live status badge
- **Vertical timeline** — ordered event list with connector lines and per-kind icons (Brain for thinking, ArrowRight for handoff, Wrench for tool-call, MessageSquare for message, etc.)
- **Thinking collapse** — consecutive thinking events from the same agent are collapsed into a single "×N" row
- **Message preview** — message events show a truncated content preview
- **Jump to message** — clicking a message or run-started event scrolls the ChatPane to the corresponding message with a brief indigo highlight ring
- **Agent badges** — multi-agent runs show agent lane badges at the top of the expanded timeline
- **Duration footer** — completed runs show total wall-clock duration
- **Status animations** — running events pulse, completed events show green checks, failed events show red alerts
## Validation completed
- `bun run typecheck`
- `bun test`
- `bun run sidecar:test`
- `bun test` (81 tests, 0 failures)
- `bun run sidecar:test` (55 tests, 0 failures)
- `bun run build`
+11 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AppShell } from '@renderer/components/AppShell';
import { ActivityPanel } from '@renderer/components/ActivityPanel';
@@ -192,6 +192,15 @@ export default function App() {
}
};
const jumpToMessage = useCallback((messageId: string) => {
const element = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('ring-1', 'ring-indigo-500/40', 'rounded-lg');
setTimeout(() => element.classList.remove('ring-1', 'ring-indigo-500/40', 'rounded-lg'), 1500);
}
}, []);
// Determine main content
let content: React.ReactNode;
let detailPanel: React.ReactNode | undefined;
@@ -226,6 +235,7 @@ export default function App() {
activity={activityForSession}
lspProfiles={workspace.settings.tooling.lspProfiles}
mcpServers={workspace.settings.tooling.mcpServers}
onJumpToMessage={jumpToMessage}
onUpdateSessionTooling={(selection) => {
void api.updateSessionTooling({
sessionId: selectedSession.id,
+19 -1
View File
@@ -1,5 +1,5 @@
import { useMemo, type ReactNode } from 'react';
import { Activity, Server, Code, Sparkles, Users } from 'lucide-react';
import { Activity, Clock, Server, Code, Sparkles, Users } from 'lucide-react';
import {
buildAgentActivityRows,
@@ -9,6 +9,7 @@ import {
type AgentActivityRow,
type SessionActivityState,
} from '@renderer/lib/sessionActivity';
import { RunTimeline } from '@renderer/components/RunTimeline';
import { inferProvider } from '@shared/domain/models';
import type { OrchestrationMode, PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern';
import {
@@ -158,6 +159,7 @@ interface ActivityPanelProps {
activity?: SessionActivityState;
lspProfiles: LspProfileDefinition[];
mcpServers: McpServerDefinition[];
onJumpToMessage?: (messageId: string) => void;
onUpdateSessionTooling: (selection: SessionToolingSelection) => void;
pattern: PatternDefinition;
projectIsScratchpad: boolean;
@@ -168,6 +170,7 @@ export function ActivityPanel({
activity,
lspProfiles,
mcpServers,
onJumpToMessage,
onUpdateSessionTooling,
pattern,
projectIsScratchpad,
@@ -228,6 +231,21 @@ export function ActivityPanel({
)}
</div>
{/* ── Run timeline section ─────────────────────────── */}
<div className="mb-4">
<SectionHeader>
<Clock className="size-3" />
<span>Timeline</span>
{session.runs.length > 0 && (
<span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[9px] tabular-nums text-zinc-500">
{session.runs.length}
</span>
)}
</SectionHeader>
<RunTimeline onJumpToMessage={onJumpToMessage} runs={session.runs} />
</div>
{/* ── Tools section ────────────────────────────────── */}
<div>
<SectionHeader>
+1 -1
View File
@@ -391,7 +391,7 @@ export function ChatPane({
phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined;
return (
<div className="group py-3" key={message.id}>
<div className="group py-3" data-message-id={message.id} key={message.id}>
<div className="flex gap-3">
<div
className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${
+303
View File
@@ -0,0 +1,303 @@
import { useMemo, useState, type ReactNode } from 'react';
import {
AlertTriangle,
ArrowRight,
Bot,
Brain,
CheckCircle2,
ChevronDown,
ChevronRight,
CircleDot,
MessageSquare,
Play,
Wrench,
XCircle,
} from 'lucide-react';
import {
collapseTimelineEvents,
formatEventLabel,
formatRunDuration,
formatRunStatusLabel,
formatRunTimestamp,
isTerminalEvent,
truncateContent,
type CollapsedTimelineEvent,
} from '@renderer/lib/runTimelineFormatting';
import type { OrchestrationMode } from '@shared/domain/pattern';
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
/* ── Mode accent colours (shared with ActivityPanel) ───────── */
const modeAccent: Record<OrchestrationMode, { dot: string; ring: string; text: string }> = {
single: { dot: 'bg-indigo-400', ring: 'ring-indigo-500/30', text: 'text-indigo-400' },
sequential: { dot: 'bg-amber-400', ring: 'ring-amber-500/30', text: 'text-amber-400' },
concurrent: { dot: 'bg-emerald-400', ring: 'ring-emerald-500/30', text: 'text-emerald-400' },
handoff: { dot: 'bg-sky-400', ring: 'ring-sky-500/30', text: 'text-sky-400' },
'group-chat': { dot: 'bg-violet-400', ring: 'ring-violet-500/30', text: 'text-violet-400' },
magentic: { dot: 'bg-zinc-500', ring: 'ring-zinc-600/30', text: 'text-zinc-500' },
};
/* ── Status badges ─────────────────────────────────────────── */
const runStatusStyles: Record<SessionRunRecord['status'], { icon: ReactNode; className: string }> = {
running: { icon: <CircleDot className="size-3" />, className: 'text-blue-400' },
completed: { icon: <CheckCircle2 className="size-3" />, className: 'text-emerald-400' },
error: { icon: <XCircle className="size-3" />, className: 'text-red-400' },
};
/* ── Event node icon ───────────────────────────────────────── */
function EventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) {
const base = 'size-3.5';
switch (kind) {
case 'run-started':
return <Play className={`${base} text-zinc-500`} />;
case 'thinking':
return <Brain className={`${base} ${status === 'running' ? 'text-sky-400 animate-pulse' : 'text-zinc-500'}`} />;
case 'handoff':
return <ArrowRight className={`${base} text-amber-400`} />;
case 'tool-call':
return <Wrench className={`${base} text-violet-400`} />;
case 'message':
return <MessageSquare className={`${base} ${status === 'running' ? 'text-blue-400 animate-pulse' : status === 'error' ? 'text-red-400' : 'text-emerald-400'}`} />;
case 'run-completed':
return <CheckCircle2 className={`${base} text-emerald-400`} />;
case 'run-failed':
return <AlertTriangle className={`${base} text-red-400`} />;
}
}
/* ── Single timeline event row ─────────────────────────────── */
function TimelineEventRow({
event,
isLast,
onJumpToMessage,
}: {
event: RunTimelineEventRecord;
isLast: boolean;
onJumpToMessage?: (messageId: string) => void;
}) {
const label = formatEventLabel(event);
const timestamp = formatRunTimestamp(event.updatedAt ?? event.occurredAt);
const preview = event.kind === 'message' ? truncateContent(event.content) : undefined;
const isClickable = !!onJumpToMessage && !!event.messageId;
const terminal = isTerminalEvent(event.kind);
return (
<button
className={`group relative flex w-full gap-2.5 text-left ${terminal ? 'py-1' : 'py-1.5'} ${isClickable ? 'cursor-pointer' : 'cursor-default'}`}
disabled={!isClickable}
onClick={isClickable ? () => onJumpToMessage(event.messageId!) : undefined}
type="button"
>
{/* Vertical connector line */}
{!isLast && (
<div className="absolute left-[7px] top-[22px] bottom-0 w-px bg-zinc-800" />
)}
{/* Node */}
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
<div className="flex size-[15px] items-center justify-center rounded-full bg-[var(--color-surface-1)]">
<EventIcon kind={event.kind} status={event.status} />
</div>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className={`text-[11px] font-medium ${terminal ? 'text-zinc-600' : 'text-zinc-300'} ${isClickable ? 'group-hover:text-indigo-300' : ''}`}>
{label}
</span>
<span className="ml-auto shrink-0 text-[9px] tabular-nums text-zinc-700">{timestamp}</span>
</div>
{/* Content preview for message events */}
{preview && (
<p className={`mt-0.5 text-[10px] leading-snug text-zinc-600 ${isClickable ? 'group-hover:text-zinc-500' : ''}`}>
{preview}
</p>
)}
{/* Error detail */}
{event.error && (
<p className="mt-0.5 text-[10px] leading-snug text-red-500/80">
{truncateContent(event.error, 120)}
</p>
)}
</div>
</button>
);
}
/* ── Collapsed thinking group ──────────────────────────────── */
function ThinkingGroupRow({
events,
agentName,
isLast,
}: {
events: RunTimelineEventRecord[];
agentName: string;
isLast: boolean;
}) {
return (
<div className="group relative flex w-full gap-2.5 py-1">
{!isLast && (
<div className="absolute left-[7px] top-[22px] bottom-0 w-px bg-zinc-800" />
)}
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
<div className="flex size-[15px] items-center justify-center rounded-full bg-[var(--color-surface-1)]">
<Brain className="size-3.5 text-zinc-500" />
</div>
</div>
<div className="min-w-0 flex-1">
<span className="text-[11px] text-zinc-600">
{agentName ? `${agentName} thinking` : 'Thinking'} ×{events.length}
</span>
</div>
</div>
);
}
/* ── Collapsed event dispatcher ────────────────────────────── */
function CollapsedEventRow({
item,
isLast,
onJumpToMessage,
}: {
item: CollapsedTimelineEvent;
isLast: boolean;
onJumpToMessage?: (messageId: string) => void;
}) {
if (item.type === 'thinking-group') {
return <ThinkingGroupRow agentName={item.agentName} events={item.events} isLast={isLast} />;
}
return <TimelineEventRow event={item.event} isLast={isLast} onJumpToMessage={onJumpToMessage} />;
}
/* ── Run card ──────────────────────────────────────────────── */
function RunCard({
run,
isLatest,
onJumpToMessage,
}: {
run: SessionRunRecord;
isLatest: boolean;
onJumpToMessage?: (messageId: string) => void;
}) {
const [expanded, setExpanded] = useState(isLatest);
const accent = modeAccent[run.patternMode] ?? modeAccent.single;
const statusStyle = runStatusStyles[run.status];
const duration = formatRunDuration(run.startedAt, run.completedAt);
const collapsedEvents = useMemo(
() => collapseTimelineEvents(run.events),
[run.events],
);
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
{/* Run header */}
<button
className="flex w-full items-center gap-2 px-3 py-2 text-left transition hover:bg-zinc-800/30"
onClick={() => setExpanded(!expanded)}
type="button"
>
{expanded
? <ChevronDown className="size-3 shrink-0 text-zinc-600" />
: <ChevronRight className="size-3 shrink-0 text-zinc-600" />}
<Bot className={`size-3 shrink-0 ${accent.text}`} />
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-zinc-300">
{run.patternName}
</span>
{/* Status */}
<span className={`flex items-center gap-1 shrink-0 ${statusStyle.className}`}>
{run.status === 'running' && <span className="size-1.5 animate-pulse rounded-full bg-blue-400" />}
{run.status !== 'running' && statusStyle.icon}
<span className="text-[9px] font-medium">{formatRunStatusLabel(run.status)}</span>
</span>
</button>
{/* Expanded timeline */}
{expanded && (
<div className="border-t border-zinc-800/60 px-3 pb-2 pt-1.5">
{/* Agent badges */}
{run.agents.length > 1 && (
<div className="mb-2 flex flex-wrap gap-1">
{run.agents.map((agent) => (
<span
className="rounded-full bg-zinc-800/80 px-2 py-0.5 text-[9px] font-medium text-zinc-500"
key={agent.agentId}
>
{agent.agentName}
</span>
))}
</div>
)}
{/* Timeline events */}
<div>
{collapsedEvents.map((item, index) => (
<CollapsedEventRow
isLast={index === collapsedEvents.length - 1}
item={item}
key={item.type === 'single' ? item.event.id : `thinking-${item.events[0].id}`}
onJumpToMessage={onJumpToMessage}
/>
))}
</div>
{/* Duration footer */}
{duration && (
<div className="mt-1 border-t border-zinc-800/40 pt-1.5 text-[9px] tabular-nums text-zinc-700">
Duration: {duration}
</div>
)}
</div>
)}
</div>
);
}
/* ── Empty state ───────────────────────────────────────────── */
function EmptyTimeline() {
return (
<p className="py-4 text-center text-[11px] text-zinc-600">
Send a message to see the run timeline
</p>
);
}
/* ── Main export ───────────────────────────────────────────── */
interface RunTimelineProps {
runs: readonly SessionRunRecord[];
onJumpToMessage?: (messageId: string) => void;
}
export function RunTimeline({ runs, onJumpToMessage }: RunTimelineProps) {
if (runs.length === 0) {
return <EmptyTimeline />;
}
return (
<div className="space-y-2">
{runs.map((run, index) => (
<RunCard
isLatest={index === 0}
key={run.id}
onJumpToMessage={onJumpToMessage}
run={run}
/>
))}
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import type {
RunTimelineEventKind,
RunTimelineEventRecord,
SessionRunRecord,
SessionRunStatus,
} from '@shared/domain/runTimeline';
export function formatRunTimestamp(isoDate: string): string {
try {
const date = new Date(isoDate);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
} catch {
return '';
}
}
export function formatRunDuration(startedAt: string, completedAt?: string): string | undefined {
if (!completedAt) {
return undefined;
}
try {
const start = new Date(startedAt).getTime();
const end = new Date(completedAt).getTime();
const diffMs = end - start;
if (diffMs < 0 || !Number.isFinite(diffMs)) {
return undefined;
}
if (diffMs < 1000) {
return `${diffMs}ms`;
}
const seconds = Math.round(diffMs / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
} catch {
return undefined;
}
}
export function formatRunStatusLabel(status: SessionRunStatus): string {
switch (status) {
case 'running':
return 'Running';
case 'completed':
return 'Completed';
case 'error':
return 'Failed';
}
}
export function formatEventLabel(event: RunTimelineEventRecord): string {
switch (event.kind) {
case 'run-started':
return 'Run started';
case 'thinking':
return event.agentName ? `${event.agentName} thinking` : 'Thinking';
case 'handoff':
if (event.sourceAgentName && event.targetAgentName) {
return `${event.sourceAgentName}${event.targetAgentName}`;
}
return event.targetAgentName ? `Handoff to ${event.targetAgentName}` : 'Handoff';
case 'tool-call':
return event.toolName
? `${event.agentName ?? 'Agent'} used ${event.toolName}`
: `${event.agentName ?? 'Agent'} tool call`;
case 'message':
return event.agentName ?? 'Response';
case 'run-completed':
return 'Completed';
case 'run-failed':
return 'Failed';
}
}
export type CollapsedTimelineEvent =
| { type: 'single'; event: RunTimelineEventRecord }
| { type: 'thinking-group'; events: RunTimelineEventRecord[]; agentName: string };
export function collapseTimelineEvents(events: readonly RunTimelineEventRecord[]): CollapsedTimelineEvent[] {
const result: CollapsedTimelineEvent[] = [];
let pendingThinking: RunTimelineEventRecord[] = [];
let pendingThinkingAgent = '';
function flushThinking() {
if (pendingThinking.length === 0) return;
if (pendingThinking.length === 1) {
result.push({ type: 'single', event: pendingThinking[0] });
} else {
result.push({ type: 'thinking-group', events: pendingThinking, agentName: pendingThinkingAgent });
}
pendingThinking = [];
pendingThinkingAgent = '';
}
for (const event of events) {
if (event.kind === 'thinking') {
const agent = event.agentName ?? '';
if (pendingThinking.length > 0 && agent === pendingThinkingAgent) {
pendingThinking.push(event);
} else {
flushThinking();
pendingThinking = [event];
pendingThinkingAgent = agent;
}
} else {
flushThinking();
result.push({ type: 'single', event });
}
}
flushThinking();
return result;
}
const eventKindOrder: Record<RunTimelineEventKind, number> = {
'run-started': 0,
'thinking': 1,
'handoff': 2,
'tool-call': 3,
'message': 4,
'run-completed': 5,
'run-failed': 5,
};
export function isTerminalEvent(kind: RunTimelineEventKind): boolean {
return kind === 'run-completed' || kind === 'run-failed' || kind === 'run-started';
}
export function eventSortKey(event: RunTimelineEventRecord): number {
return eventKindOrder[event.kind] ?? 4;
}
export function truncateContent(content: string | undefined, maxLength = 80): string | undefined {
if (!content) return undefined;
const singleLine = content.replace(/\n/g, ' ').trim();
if (singleLine.length <= maxLength) return singleLine;
return `${singleLine.slice(0, maxLength)}`;
}
export function findLatestRun(runs: readonly SessionRunRecord[]): SessionRunRecord | undefined {
return runs.length > 0 ? runs[0] : undefined;
}
@@ -0,0 +1,125 @@
import { describe, expect, test } from 'bun:test';
import {
collapseTimelineEvents,
formatEventLabel,
formatRunDuration,
formatRunStatusLabel,
formatRunTimestamp,
truncateContent,
} from '@renderer/lib/runTimelineFormatting';
import type { RunTimelineEventRecord } from '@shared/domain/runTimeline';
function createEvent(overrides?: Partial<RunTimelineEventRecord>): RunTimelineEventRecord {
return {
id: 'run-event-1',
kind: 'thinking',
occurredAt: '2026-03-23T12:00:00.000Z',
status: 'completed',
...overrides,
};
}
describe('run timeline formatting', () => {
test('formats ISO timestamps as local time strings', () => {
const result = formatRunTimestamp('2026-03-23T14:30:45.000Z');
expect(result).toMatch(/\d{2}:\d{2}:\d{2}/);
});
test('returns empty string for invalid timestamps', () => {
expect(formatRunTimestamp('not-a-date')).toBe('');
});
test('formats run durations in human-readable form', () => {
expect(formatRunDuration('2026-03-23T12:00:00.000Z', '2026-03-23T12:00:00.500Z')).toBe('500ms');
expect(formatRunDuration('2026-03-23T12:00:00.000Z', '2026-03-23T12:00:05.000Z')).toBe('5s');
expect(formatRunDuration('2026-03-23T12:00:00.000Z', '2026-03-23T12:01:30.000Z')).toBe('1m 30s');
expect(formatRunDuration('2026-03-23T12:00:00.000Z', '2026-03-23T12:02:00.000Z')).toBe('2m');
});
test('returns undefined for missing or invalid durations', () => {
expect(formatRunDuration('2026-03-23T12:00:00.000Z', undefined)).toBeUndefined();
expect(formatRunDuration('2026-03-23T12:00:05.000Z', '2026-03-23T12:00:00.000Z')).toBeUndefined();
});
test('formats run status labels', () => {
expect(formatRunStatusLabel('running')).toBe('Running');
expect(formatRunStatusLabel('completed')).toBe('Completed');
expect(formatRunStatusLabel('error')).toBe('Failed');
});
test('formats event labels with agent and tool context', () => {
expect(formatEventLabel(createEvent({ kind: 'run-started' }))).toBe('Run started');
expect(formatEventLabel(createEvent({ kind: 'thinking', agentName: 'Writer' }))).toBe('Writer thinking');
expect(formatEventLabel(createEvent({ kind: 'thinking' }))).toBe('Thinking');
expect(formatEventLabel(createEvent({
kind: 'handoff',
sourceAgentName: 'Writer',
targetAgentName: 'Reviewer',
}))).toBe('Writer → Reviewer');
expect(formatEventLabel(createEvent({
kind: 'handoff',
targetAgentName: 'Reviewer',
}))).toBe('Handoff to Reviewer');
expect(formatEventLabel(createEvent({
kind: 'tool-call',
agentName: 'Writer',
toolName: 'file_search',
}))).toBe('Writer used file_search');
expect(formatEventLabel(createEvent({ kind: 'message', agentName: 'Reviewer' }))).toBe('Reviewer');
expect(formatEventLabel(createEvent({ kind: 'run-completed' }))).toBe('Completed');
expect(formatEventLabel(createEvent({ kind: 'run-failed' }))).toBe('Failed');
});
test('truncates long content with an ellipsis', () => {
expect(truncateContent(undefined)).toBeUndefined();
expect(truncateContent('Short content')).toBe('Short content');
expect(truncateContent('A'.repeat(100), 80)).toBe('A'.repeat(80) + '…');
expect(truncateContent('Line 1\nLine 2')).toBe('Line 1 Line 2');
});
test('collapses consecutive thinking events from the same agent', () => {
const events: RunTimelineEventRecord[] = [
createEvent({ id: 'e1', kind: 'run-started' }),
createEvent({ id: 'e2', kind: 'thinking', agentName: 'Writer' }),
createEvent({ id: 'e3', kind: 'thinking', agentName: 'Writer' }),
createEvent({ id: 'e4', kind: 'thinking', agentName: 'Writer' }),
createEvent({ id: 'e5', kind: 'message', agentName: 'Writer', messageId: 'msg-1' }),
createEvent({ id: 'e6', kind: 'run-completed' }),
];
const collapsed = collapseTimelineEvents(events);
expect(collapsed).toEqual([
{ type: 'single', event: events[0] },
{ type: 'thinking-group', events: [events[1], events[2], events[3]], agentName: 'Writer' },
{ type: 'single', event: events[4] },
{ type: 'single', event: events[5] },
]);
});
test('does not collapse thinking events from different agents', () => {
const events: RunTimelineEventRecord[] = [
createEvent({ id: 'e1', kind: 'thinking', agentName: 'Writer' }),
createEvent({ id: 'e2', kind: 'thinking', agentName: 'Reviewer' }),
];
const collapsed = collapseTimelineEvents(events);
expect(collapsed).toEqual([
{ type: 'single', event: events[0] },
{ type: 'single', event: events[1] },
]);
});
test('keeps a single thinking event as a single item', () => {
const events: RunTimelineEventRecord[] = [
createEvent({ id: 'e1', kind: 'thinking', agentName: 'Writer' }),
];
const collapsed = collapseTimelineEvents(events);
expect(collapsed).toEqual([
{ type: 'single', event: events[0] },
]);
});
});