From 0d3f1cbd501f8af74401b6ee9888396b6df887c7 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 24 Mar 2026 00:17:14 +0100 Subject: [PATCH] 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> --- HANDOVER.md | 26 +- src/renderer/App.tsx | 12 +- src/renderer/components/ActivityPanel.tsx | 20 +- src/renderer/components/ChatPane.tsx | 2 +- src/renderer/components/RunTimeline.tsx | 303 +++++++++++++++++++ src/renderer/lib/runTimelineFormatting.ts | 157 ++++++++++ tests/renderer/runTimelineFormatting.test.ts | 125 ++++++++ 7 files changed, 640 insertions(+), 5 deletions(-) create mode 100644 src/renderer/components/RunTimeline.tsx create mode 100644 src/renderer/lib/runTimelineFormatting.ts create mode 100644 tests/renderer/runTimelineFormatting.test.ts diff --git a/HANDOVER.md b/HANDOVER.md index 9c7bea8..114af7e 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -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` diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5a6a38d..223ba73 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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, diff --git a/src/renderer/components/ActivityPanel.tsx b/src/renderer/components/ActivityPanel.tsx index 0e4b871..0a0175e 100644 --- a/src/renderer/components/ActivityPanel.tsx +++ b/src/renderer/components/ActivityPanel.tsx @@ -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({ )} + {/* ── Run timeline section ─────────────────────────── */} +
+ + + Timeline + {session.runs.length > 0 && ( + + {session.runs.length} + + )} + + + +
+ {/* ── Tools section ────────────────────────────────── */}
diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 9f25fda..3703180 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -391,7 +391,7 @@ export function ChatPane({ phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined; return ( -
+
= { + 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 = { + running: { icon: , className: 'text-blue-400' }, + completed: { icon: , className: 'text-emerald-400' }, + error: { icon: , 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 ; + case 'thinking': + return ; + case 'handoff': + return ; + case 'tool-call': + return ; + case 'message': + return ; + case 'run-completed': + return ; + case 'run-failed': + return ; + } +} + +/* ── 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 ( + + ); +} + +/* ── Collapsed thinking group ──────────────────────────────── */ + +function ThinkingGroupRow({ + events, + agentName, + isLast, +}: { + events: RunTimelineEventRecord[]; + agentName: string; + isLast: boolean; +}) { + return ( +
+ {!isLast && ( +
+ )} +
+
+ +
+
+
+ + {agentName ? `${agentName} thinking` : 'Thinking'} ×{events.length} + +
+
+ ); +} + +/* ── Collapsed event dispatcher ────────────────────────────── */ + +function CollapsedEventRow({ + item, + isLast, + onJumpToMessage, +}: { + item: CollapsedTimelineEvent; + isLast: boolean; + onJumpToMessage?: (messageId: string) => void; +}) { + if (item.type === 'thinking-group') { + return ; + } + return ; +} + +/* ── 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 ( +
+ {/* Run header */} + + + {/* Expanded timeline */} + {expanded && ( +
+ {/* Agent badges */} + {run.agents.length > 1 && ( +
+ {run.agents.map((agent) => ( + + {agent.agentName} + + ))} +
+ )} + + {/* Timeline events */} +
+ {collapsedEvents.map((item, index) => ( + + ))} +
+ + {/* Duration footer */} + {duration && ( +
+ Duration: {duration} +
+ )} +
+ )} +
+ ); +} + +/* ── Empty state ───────────────────────────────────────────── */ + +function EmptyTimeline() { + return ( +

+ Send a message to see the run timeline +

+ ); +} + +/* ── Main export ───────────────────────────────────────────── */ + +interface RunTimelineProps { + runs: readonly SessionRunRecord[]; + onJumpToMessage?: (messageId: string) => void; +} + +export function RunTimeline({ runs, onJumpToMessage }: RunTimelineProps) { + if (runs.length === 0) { + return ; + } + + return ( +
+ {runs.map((run, index) => ( + + ))} +
+ ); +} diff --git a/src/renderer/lib/runTimelineFormatting.ts b/src/renderer/lib/runTimelineFormatting.ts new file mode 100644 index 0000000..63e65e5 --- /dev/null +++ b/src/renderer/lib/runTimelineFormatting.ts @@ -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 = { + '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; +} diff --git a/tests/renderer/runTimelineFormatting.test.ts b/tests/renderer/runTimelineFormatting.test.ts new file mode 100644 index 0000000..8e7e0c7 --- /dev/null +++ b/tests/renderer/runTimelineFormatting.test.ts @@ -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 { + 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] }, + ]); + }); +});