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 ─────────────────────────── */}
+