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