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
+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;
}