Add progress visualization components: ProgressBadge, ProgressGroup, AgentProgressView
Three new components for rendering tool progress events inline: ProgressBadge — Attaches below tool_call messages. Shows color-coded pills counting events by subtype (hook/bash/mcp/agent). Expands on click to show either a timestamped event log (mixed subtypes) or the full AgentProgressView (all-agent subtypes). Lazy-renders markdown only when expanded via useMemo. ProgressGroup — Standalone progress divider for orphaned progress events in the message stream. Centered pill-style summary with event count, subtype breakdown, and time range. Expands to show the same timestamped log format as ProgressBadge. AgentProgressView — Rich drill-down view for agent sub-conversations. Parses agent_progress JSON via parseAgentEvents into a structured activity feed with: - Header showing quoted prompt, agent ID, turn count, and time range - Per-event rows with timestamp, SVG icon (10 tool-specific icons), short tool name, and summarized description - Click-to-expand drill-down rendering tool inputs as JSON code blocks, tool results with language-detected syntax highlighting (after stripping cat-n line numbers), and text responses as markdown Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
353
src/client/components/AgentProgressView.tsx
Normal file
353
src/client/components/AgentProgressView.tsx
Normal file
@@ -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 (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 1.5H4a1 1 0 00-1 1v11a1 1 0 001 1h8a1 1 0 001-1V5.5L9 1.5z" />
|
||||||
|
<path d="M9 1.5V5.5h4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconSearch() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="7" cy="7" r="4.5" />
|
||||||
|
<path d="M10.5 10.5L14 14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconFolder() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 4.5V12a1 1 0 001 1h10a1 1 0 001-1V6a1 1 0 00-1-1H8L6.5 3.5H3a1 1 0 00-1 1z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconTerminal() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 3.5h12a1 1 0 011 1v7a1 1 0 01-1 1H2a1 1 0 01-1-1v-7a1 1 0 011-1z" />
|
||||||
|
<path d="M4 7l2 1.5L4 10" />
|
||||||
|
<path d="M8 10h3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconPencil() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconAgent() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="2" width="10" height="8" rx="1" />
|
||||||
|
<circle cx="6" cy="6" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="10" cy="6" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<path d="M5 13v-3h6v3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconGlobe() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="8" cy="8" r="6.5" />
|
||||||
|
<path d="M1.5 8h13M8 1.5c-2 2.5-2 9.5 0 13M8 1.5c2 2.5 2 9.5 0 13" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconWrench() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M10 2a4 4 0 00-3.87 5.03L2 11.17V14h2.83l4.14-4.13A4 4 0 0010 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconCheck() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 8.5l3.5 3.5L13 4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconChat() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 2.5h12a1 1 0 011 1v7a1 1 0 01-1 1H5l-3 3V3.5a1 1 0 011-1z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconNote() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M4 2.5h8a1 1 0 011 1v9a1 1 0 01-1 1H4a1 1 0 01-1-1v-9a1 1 0 011-1z" />
|
||||||
|
<path d="M5.5 5.5h5M5.5 8h5M5.5 10.5h3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Icon selection ────────────────────────────────────────
|
||||||
|
|
||||||
|
function ToolIcon({ name }: { name: string }) {
|
||||||
|
const short = shortToolName(name);
|
||||||
|
switch (short) {
|
||||||
|
case "Read": return <IconFile />;
|
||||||
|
case "Grep": case "WarpGrep": return <IconSearch />;
|
||||||
|
case "Glob": return <IconFolder />;
|
||||||
|
case "Bash": return <IconTerminal />;
|
||||||
|
case "Write": case "Edit": case "FastEdit": return <IconPencil />;
|
||||||
|
case "Task": return <IconAgent />;
|
||||||
|
case "WebFetch": case "WebSearch": return <IconGlobe />;
|
||||||
|
default: return <IconWrench />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventIcon({ event }: { event: AgentEvent }) {
|
||||||
|
switch (event.kind) {
|
||||||
|
case "tool_call": return <ToolIcon name={event.toolName} />;
|
||||||
|
case "tool_result": return <IconCheck />;
|
||||||
|
case "text_response": return <IconChat />;
|
||||||
|
case "user_text": return <IconNote />;
|
||||||
|
case "raw_content": return <IconWrench />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div>
|
||||||
|
{label && (
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-foreground-muted mb-1">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="prose-message-progress max-h-96 overflow-y-auto"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrillDown({ event }: { event: AgentEvent }) {
|
||||||
|
if (event.kind === "tool_call") {
|
||||||
|
const jsonBlock = "```json\n" + JSON.stringify(event.input, null, 2) + "\n```";
|
||||||
|
return <RenderedMarkdown content={jsonBlock} label="Input" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <RenderedMarkdown content={wrapped} label="Result" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === "text_response") {
|
||||||
|
return <RenderedMarkdown content={event.text} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === "user_text") {
|
||||||
|
return <RenderedMarkdown content={event.text} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw_content
|
||||||
|
return <RenderedMarkdown content={(event as { content: string }).content} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AgentProgressView({ events }: Props) {
|
||||||
|
const [expandedIndex, setExpandedIndex] = useState<number | null>(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 (
|
||||||
|
<div data-testid="agent-progress-view" className="flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-2.5 py-2 border-b border-border-muted">
|
||||||
|
{promptPreview && (
|
||||||
|
<div
|
||||||
|
data-testid="agent-prompt"
|
||||||
|
className="text-xs text-foreground font-medium leading-snug mb-0.5"
|
||||||
|
>
|
||||||
|
“{promptPreview}”
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-[10px] text-foreground-muted font-mono leading-tight">
|
||||||
|
Agent {parsed.agentId || "unknown"}
|
||||||
|
{" \u00B7 "}
|
||||||
|
{parsed.turnCount} turn{parsed.turnCount !== 1 ? "s" : ""}
|
||||||
|
{parsed.firstTimestamp && (
|
||||||
|
<>
|
||||||
|
{" \u00B7 "}
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatTimeRange(parsed.firstTimestamp, parsed.lastTimestamp)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity feed */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{parsed.events.map((event, i) => {
|
||||||
|
const isExpanded = expandedIndex === i;
|
||||||
|
return (
|
||||||
|
<div key={i}>
|
||||||
|
<button
|
||||||
|
data-testid="agent-event-row"
|
||||||
|
onClick={() => setExpandedIndex(isExpanded ? null : i)}
|
||||||
|
className={`w-full flex items-center gap-2 px-2.5 py-1.5 text-left font-mono text-[11px] hover:bg-surface-overlay/50 transition-colors ${
|
||||||
|
isExpanded ? "bg-surface-overlay/30" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-14 flex-shrink-0 whitespace-nowrap tabular-nums text-foreground-muted">
|
||||||
|
{formatTime(event.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-shrink-0 w-4 text-foreground-muted">
|
||||||
|
<EventIcon event={event} />
|
||||||
|
</span>
|
||||||
|
<span className="w-14 flex-shrink-0 whitespace-nowrap font-semibold text-foreground-secondary">
|
||||||
|
{summaryToolName(event)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0 truncate text-foreground-secondary">
|
||||||
|
{summaryLabel(event)}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 flex-shrink-0 text-foreground-muted transition-transform duration-150 ${
|
||||||
|
isExpanded ? "rotate-90" : ""
|
||||||
|
}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div
|
||||||
|
data-testid="agent-drilldown"
|
||||||
|
className="px-2.5 py-2 bg-surface-inset/50"
|
||||||
|
>
|
||||||
|
<DrillDown event={event} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/client/components/ProgressBadge.tsx
Normal file
116
src/client/components/ProgressBadge.tsx
Normal file
@@ -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<ProgressSubtype, string> = {
|
||||||
|
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<Record<ProgressSubtype, number>> = {};
|
||||||
|
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 (
|
||||||
|
<div className="mt-1 px-5 pb-3">
|
||||||
|
{/* Pill row */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center gap-1.5 text-caption"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 text-foreground-muted transition-transform duration-150 ${expanded ? "rotate-90" : ""}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
{(Object.entries(counts) as [ProgressSubtype, number][]).map(
|
||||||
|
([sub, count]) => (
|
||||||
|
<span
|
||||||
|
key={sub}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded font-mono text-[11px] ${SUBTYPE_COLORS[sub]}`}
|
||||||
|
>
|
||||||
|
{sub}: {count}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded drawer */}
|
||||||
|
{expanded && allAgent && (
|
||||||
|
<div
|
||||||
|
data-testid="progress-expanded"
|
||||||
|
className="mt-2 rounded-lg bg-surface-inset border border-border-muted overflow-hidden"
|
||||||
|
>
|
||||||
|
<AgentProgressView events={events} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && !allAgent && (
|
||||||
|
<div
|
||||||
|
data-testid="progress-expanded"
|
||||||
|
className="mt-2 rounded-lg bg-surface-inset border border-border-muted p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{renderedEvents.map((e) => (
|
||||||
|
<div key={e.uuid} className="flex items-start font-mono text-[11px]">
|
||||||
|
<span className="w-20 flex-shrink-0 whitespace-nowrap tabular-nums text-foreground-muted">
|
||||||
|
{formatTime(e.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span className={`w-16 flex-shrink-0 whitespace-nowrap font-medium ${SUBTYPE_COLORS[e.subtype].split(" ")[0]}`}>
|
||||||
|
{e.subtype}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="prose-message-progress flex-1 min-w-0"
|
||||||
|
dangerouslySetInnerHTML={{ __html: e.html }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/client/components/ProgressGroup.tsx
Normal file
118
src/client/components/ProgressGroup.tsx
Normal file
@@ -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<ProgressSubtype, string> = {
|
||||||
|
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<Record<ProgressSubtype, number>> = {};
|
||||||
|
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 (
|
||||||
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="w-full flex items-center gap-3 group/pg"
|
||||||
|
>
|
||||||
|
<div className="flex-1 h-px bg-border-muted" />
|
||||||
|
<span className="text-[11px] font-mono text-foreground-muted flex-shrink-0 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
{events.length} progress event{events.length !== 1 ? "s" : ""}
|
||||||
|
<span className="text-foreground-muted/60">·</span>
|
||||||
|
{summary}
|
||||||
|
{range && (
|
||||||
|
<>
|
||||||
|
<span className="text-foreground-muted/60">·</span>
|
||||||
|
<span className="tabular-nums">{range}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-border-muted" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-2 mb-1 mx-auto max-w-5xl rounded-lg bg-surface-inset border border-border-muted p-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{renderedEvents.map((e) => (
|
||||||
|
<div key={e.uuid} className="flex items-start font-mono text-[11px]">
|
||||||
|
<span className="w-20 flex-shrink-0 whitespace-nowrap tabular-nums text-foreground-muted">
|
||||||
|
{formatTime(e.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span className={`w-16 flex-shrink-0 whitespace-nowrap font-medium ${SUBTYPE_COLORS[e.subtype]}`}>
|
||||||
|
{e.subtype}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="prose-message-progress flex-1 min-w-0"
|
||||||
|
dangerouslySetInnerHTML={{ __html: e.html }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user