diff --git a/src/client/components/AgentProgressView.tsx b/src/client/components/AgentProgressView.tsx
new file mode 100644
index 0000000..34f406c
--- /dev/null
+++ b/src/client/components/AgentProgressView.tsx
@@ -0,0 +1,353 @@
+import React, { useState, useMemo } from "react";
+import type { ParsedMessage } from "../lib/types";
+import {
+ parseAgentEvents,
+ summarizeToolCall,
+ stripLineNumbers,
+ type AgentEvent,
+ type ParsedAgentProgress,
+} from "../lib/agent-progress-parser";
+import { renderMarkdown } from "../lib/markdown";
+
+interface Props {
+ events: ParsedMessage[];
+}
+
+// ── Helpers ────────────────────────────────────────────────
+
+function formatTime(ts?: string): string {
+ if (!ts) return "";
+ const d = new Date(ts);
+ if (isNaN(d.getTime())) return "";
+ return d.toLocaleTimeString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+}
+
+function formatTimeRange(first?: string, last?: string): string {
+ if (!first) return "";
+ const f = formatTime(first);
+ const l = formatTime(last);
+ if (!l || f === l) return f;
+ return `${f}\u2013${l}`;
+}
+
+function shortToolName(name: string): string {
+ if (name.startsWith("mcp__morph-mcp__")) {
+ const short = name.slice("mcp__morph-mcp__".length);
+ if (short === "warpgrep_codebase_search") return "WarpGrep";
+ if (short === "edit_file") return "FastEdit";
+ return short;
+ }
+ if (name.startsWith("mcp__")) {
+ const parts = name.split("__");
+ return parts[parts.length - 1] || name;
+ }
+ return name;
+}
+
+// ── SVG Icons (16x16) ─────────────────────────────────────
+
+function IconFile() {
+ return (
+
+ );
+}
+
+function IconSearch() {
+ return (
+
+ );
+}
+
+function IconFolder() {
+ return (
+
+ );
+}
+
+function IconTerminal() {
+ return (
+
+ );
+}
+
+function IconPencil() {
+ return (
+
+ );
+}
+
+function IconAgent() {
+ return (
+
+ );
+}
+
+function IconGlobe() {
+ return (
+
+ );
+}
+
+function IconWrench() {
+ return (
+
+ );
+}
+
+function IconCheck() {
+ return (
+
+ );
+}
+
+function IconChat() {
+ return (
+
+ );
+}
+
+function IconNote() {
+ return (
+
+ );
+}
+
+// ── Icon selection ────────────────────────────────────────
+
+function ToolIcon({ name }: { name: string }) {
+ const short = shortToolName(name);
+ switch (short) {
+ case "Read": return ;
+ case "Grep": case "WarpGrep": return ;
+ case "Glob": return ;
+ case "Bash": return ;
+ case "Write": case "Edit": case "FastEdit": return ;
+ case "Task": return ;
+ case "WebFetch": case "WebSearch": return ;
+ default: return ;
+ }
+}
+
+function EventIcon({ event }: { event: AgentEvent }) {
+ switch (event.kind) {
+ case "tool_call": return ;
+ case "tool_result": return ;
+ case "text_response": return ;
+ case "user_text": return ;
+ case "raw_content": return ;
+ }
+}
+
+// ── Summary row label ─────────────────────────────────────
+
+function summaryLabel(event: AgentEvent): string {
+ switch (event.kind) {
+ case "tool_call":
+ return summarizeToolCall(event.toolName, event.input);
+ case "tool_result":
+ return `Result (${event.content.length.toLocaleString()} chars)`;
+ case "text_response":
+ return `Text response (${event.lineCount} lines)`;
+ case "user_text":
+ return event.text.length > 80
+ ? event.text.slice(0, 79) + "\u2026"
+ : event.text;
+ case "raw_content":
+ return event.content.length > 60
+ ? event.content.slice(0, 59) + "\u2026"
+ : event.content;
+ }
+}
+
+function summaryToolName(event: AgentEvent): string {
+ if (event.kind === "tool_call") return shortToolName(event.toolName);
+ if (event.kind === "tool_result") return "Result";
+ if (event.kind === "text_response") return "Response";
+ if (event.kind === "user_text") return "Prompt";
+ return "Raw";
+}
+
+// ── Drill-down content ────────────────────────────────────
+// All content originates from local JSONL session files owned by the user.
+// Same trust model as MessageBubble's markdown rendering.
+// This is a local-only developer tool, not exposed to untrusted input.
+
+function RenderedMarkdown({ content, label }: { content: string; label?: string }) {
+ const html = useMemo(() => renderMarkdown(content), [content]);
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+ );
+}
+
+function DrillDown({ event }: { event: AgentEvent }) {
+ if (event.kind === "tool_call") {
+ const jsonBlock = "```json\n" + JSON.stringify(event.input, null, 2) + "\n```";
+ return ;
+ }
+
+ if (event.kind === "tool_result") {
+ // Strip cat-n line number prefixes so hljs can detect syntax,
+ // then wrap in a language-tagged code fence for highlighting.
+ const stripped = stripLineNumbers(event.content);
+ const lang = event.language || "";
+ const wrapped = "```" + lang + "\n" + stripped + "\n```";
+ return ;
+ }
+
+ if (event.kind === "text_response") {
+ return ;
+ }
+
+ if (event.kind === "user_text") {
+ return ;
+ }
+
+ // raw_content
+ return ;
+}
+
+// ── Main component ────────────────────────────────────────
+
+export function AgentProgressView({ events }: Props) {
+ const [expandedIndex, setExpandedIndex] = useState(null);
+
+ const parsed: ParsedAgentProgress = useMemo(
+ () => parseAgentEvents(events),
+ [events]
+ );
+
+ const promptPreview = parsed.prompt
+ ? parsed.prompt.length > 100
+ ? parsed.prompt.slice(0, 99) + "\u2026"
+ : parsed.prompt
+ : null;
+
+ return (
+
+ {/* Header */}
+
+ {promptPreview && (
+
+ “{promptPreview}”
+
+ )}
+
+ Agent {parsed.agentId || "unknown"}
+ {" \u00B7 "}
+ {parsed.turnCount} turn{parsed.turnCount !== 1 ? "s" : ""}
+ {parsed.firstTimestamp && (
+ <>
+ {" \u00B7 "}
+
+ {formatTimeRange(parsed.firstTimestamp, parsed.lastTimestamp)}
+
+ >
+ )}
+
+
+
+ {/* Activity feed */}
+
+ {parsed.events.map((event, i) => {
+ const isExpanded = expandedIndex === i;
+ return (
+
+
+
+ {isExpanded && (
+
+
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/client/components/ProgressBadge.tsx b/src/client/components/ProgressBadge.tsx
new file mode 100644
index 0000000..746b7fb
--- /dev/null
+++ b/src/client/components/ProgressBadge.tsx
@@ -0,0 +1,116 @@
+import React, { useState, useMemo } from "react";
+import type { ParsedMessage, ProgressSubtype } from "../lib/types";
+import { renderMarkdown } from "../lib/markdown";
+import { AgentProgressView } from "./AgentProgressView";
+
+interface Props {
+ events: ParsedMessage[];
+}
+
+const SUBTYPE_COLORS: Record = {
+ hook: "text-category-hook bg-category-hook/10",
+ bash: "text-category-tool bg-category-tool/10",
+ mcp: "text-category-result bg-category-result/10",
+ agent: "text-category-thinking bg-category-thinking/10",
+};
+
+function formatTime(ts?: string): string {
+ if (!ts) return "--:--:--";
+ const d = new Date(ts);
+ if (isNaN(d.getTime())) return "--:--:--";
+ return d.toLocaleTimeString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+}
+
+export function ProgressBadge({ events }: Props) {
+ const [expanded, setExpanded] = useState(false);
+
+ // Count by subtype
+ const counts: Partial> = {};
+ for (const e of events) {
+ const sub = e.progressSubtype || "hook";
+ counts[sub] = (counts[sub] || 0) + 1;
+ }
+
+ // Check if all events are agent subtype
+ const allAgent = events.length > 0 && events.every(
+ (e) => e.progressSubtype === "agent"
+ );
+
+ // Lazily render markdown only when expanded
+ const renderedEvents = useMemo(() => {
+ if (!expanded || allAgent) return [];
+ return events.map((e) => ({
+ uuid: e.uuid,
+ timestamp: e.timestamp,
+ subtype: e.progressSubtype || "hook",
+ // Content sourced from local JSONL session files owned by the user.
+ // Same trust model as MessageBubble's markdown rendering.
+ html: renderMarkdown(e.content),
+ }));
+ }, [events, expanded, allAgent]);
+
+ return (
+
+ {/* Pill row */}
+
+
+ {/* Expanded drawer */}
+ {expanded && allAgent && (
+
+ )}
+
+ {expanded && !allAgent && (
+
+
+ {renderedEvents.map((e) => (
+
+
+ {formatTime(e.timestamp)}
+
+
+ {e.subtype}
+
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/client/components/ProgressGroup.tsx b/src/client/components/ProgressGroup.tsx
new file mode 100644
index 0000000..38a063a
--- /dev/null
+++ b/src/client/components/ProgressGroup.tsx
@@ -0,0 +1,118 @@
+import React, { useState, useMemo } from "react";
+import type { ParsedMessage, ProgressSubtype } from "../lib/types";
+import { renderMarkdown } from "../lib/markdown";
+
+interface Props {
+ events: ParsedMessage[];
+}
+
+const SUBTYPE_COLORS: Record = {
+ hook: "text-category-hook",
+ bash: "text-category-tool",
+ mcp: "text-category-result",
+ agent: "text-category-thinking",
+};
+
+function formatTime(ts?: string): string {
+ if (!ts) return "";
+ const d = new Date(ts);
+ if (isNaN(d.getTime())) return "";
+ return d.toLocaleTimeString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+}
+
+function buildSummary(events: ParsedMessage[]): string {
+ const counts: Partial> = {};
+ for (const e of events) {
+ const sub = e.progressSubtype || "hook";
+ counts[sub] = (counts[sub] || 0) + 1;
+ }
+ const parts = (Object.entries(counts) as [ProgressSubtype, number][]).map(
+ ([sub, count]) => `${count} ${sub}`
+ );
+ return parts.join(", ");
+}
+
+function timeRange(events: ParsedMessage[]): string {
+ const timestamps = events
+ .map((e) => e.timestamp)
+ .filter(Boolean) as string[];
+ if (timestamps.length === 0) return "";
+ const first = formatTime(timestamps[0]);
+ const last = formatTime(timestamps[timestamps.length - 1]);
+ if (first === last) return first;
+ return `${first}\u2013${last}`;
+}
+
+export function ProgressGroup({ events }: Props) {
+ const [expanded, setExpanded] = useState(false);
+ const summary = buildSummary(events);
+ const range = timeRange(events);
+
+ // Lazily render markdown only when expanded
+ const renderedEvents = useMemo(() => {
+ if (!expanded) return [];
+ return events.map((e) => ({
+ uuid: e.uuid,
+ timestamp: e.timestamp,
+ subtype: e.progressSubtype || "hook",
+ // Content sourced from local JSONL session files owned by the user.
+ // Same trust model as MessageBubble's markdown rendering.
+ // This is a local-only developer tool, not exposed to untrusted input.
+ html: renderMarkdown(e.content),
+ }));
+ }, [events, expanded]);
+
+ return (
+
+
+
+ {expanded && (
+
+
+ {renderedEvents.map((e) => (
+
+
+ {formatTime(e.timestamp)}
+
+
+ {e.subtype}
+
+
+
+ ))}
+
+
+ )}
+
+ );
+}