mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-05 19:38:32 +02:00
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:
+24
-2
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user