import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, ChevronRight, Layers, Terminal } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cliAppInitials } from "@/components/CliAppMentionText"; import { FileReferenceChip } from "@/components/FileReferenceChip"; import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble"; import { cn } from "@/lib/utils"; import type { CliAppInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types"; /** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */ const CLUSTER_SCROLL_MAX_CLASS = "max-h-52"; const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24; export function isReasoningOnlyAssistant(m: UIMessage): boolean { if (m.role !== "assistant" || m.kind === "trace") return false; if (m.content.trim().length > 0) return false; return !!(m.reasoning?.length || m.reasoningStreaming || m.isStreaming); } export function isAgentActivityMember(m: UIMessage): boolean { return isReasoningOnlyAssistant(m) || m.kind === "trace"; } interface ActivityCounts { reasoningSteps: number; toolCalls: number; cliCount: number; fileCount: number; added: number; deleted: number; hasDiffStats: boolean; hasEditingFiles: boolean; hasFailedFiles: boolean; primaryFilePath?: string; primaryFileTooltipPath?: string; primaryCliName?: string; primaryCliStatus?: CliRunStatus; } interface FileEditSummary { key: string; path: string; absolute_path?: string; added: number; deleted: number; approximate: boolean; binary: boolean; status: UIFileEdit["status"]; pending: boolean; error?: string; } interface CliRunSummary { key: string; name: string; args: string[]; json: boolean; workingDir?: string; status: CliRunStatus; error?: string; } type CliRunStatus = "running" | "done" | "error"; function countActivity( messages: UIMessage[], fileEdits: FileEditSummary[], cliRuns: CliRunSummary[], ): ActivityCounts { let reasoningSteps = 0; let toolCalls = 0; const cliCount = cliRuns.length; const primaryCli = cliRuns[cliRuns.length - 1]; const primaryCliName = primaryCli?.name; const primaryCliStatus = primaryCli?.status; for (const m of messages) { if (isReasoningOnlyAssistant(m)) { reasoningSteps += 1; continue; } if (m.kind === "trace") { const lines = traceLines(m); for (const line of lines) { if (!isCliRunTraceLine(line)) { toolCalls += 1; } } } } let added = 0; let deleted = 0; let hasDiffStats = false; let hasEditingFiles = false; let failedFileCount = 0; let primaryFilePath: string | undefined; let primaryFileTooltipPath: string | undefined; for (const edit of fileEdits) { primaryFilePath = edit.path; primaryFileTooltipPath = edit.absolute_path || edit.path; if (edit.status === "editing") { hasEditingFiles = true; } if (edit.status === "error") { failedFileCount += 1; } if (edit.status === "error" || edit.binary) { continue; } if (!hasVisibleDiffStats(edit)) { continue; } hasDiffStats = true; added += edit.added; deleted += edit.deleted; } return { reasoningSteps, toolCalls, cliCount, fileCount: fileEdits.length, added, deleted, hasDiffStats, hasEditingFiles, hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length, primaryFilePath, primaryFileTooltipPath, primaryCliName, primaryCliStatus, }; } interface AgentActivityClusterProps { messages: UIMessage[]; /** True while the session turn is still running (drives “Working…” copy + header sheen). */ isTurnStreaming: boolean; hasBodyBelow: boolean; cliApps?: CliAppInfo[]; } /** * Outer fold wrapping interleaved reasoning-only assistant rows and tool-trace rows. * Fixed max height with inner scroll; each block keeps its own small collapsible (reasoning / tools). */ export function AgentActivityCluster({ messages, isTurnStreaming, hasBodyBelow, cliApps = [], }: AgentActivityClusterProps) { const { t } = useTranslation(); const fileEdits = useMemo( () => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming), [messages, isTurnStreaming], ); const cliRuns = useMemo(() => collectCliRuns(messages), [messages]); const cliAppsByName = useMemo( () => new Map(cliApps.map((app) => [app.name.toLowerCase(), app])), [cliApps], ); const { reasoningSteps, toolCalls, cliCount, fileCount, added, deleted, hasDiffStats, hasEditingFiles, hasFailedFiles, primaryFilePath, primaryFileTooltipPath, primaryCliName, primaryCliStatus, } = countActivity(messages, fileEdits, cliRuns); const hasPendingFileEdit = fileEdits.some((edit) => edit.pending); const [userToggledOuter, setUserToggledOuter] = useState(false); const [outerOpenLocal, setOuterOpenLocal] = useState(false); const activityScrollRef = useRef(null); const activityContentRef = useRef(null); const autoFollowActivityRef = useRef(true); const scrollFrameRef = useRef(null); /** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */ const outerExpanded = userToggledOuter ? outerOpenLocal : false; const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles; const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming; const singleFilePath = fileCount === 1 ? primaryFilePath : undefined; const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined; const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || fileCount > 0; const fileActivitySummary = fileCount > 0 ? hasPendingFileEdit && !singleFilePath ? t("message.fileActivityPreparing", { defaultValue: "Preparing edit…" }) : singleFilePath ? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), { file: shortFileName(singleFilePath), defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`, }) : t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), { count: fileCount, defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{count}} files`, }) : ""; const cliActivitySummary = cliCount > 0 ? cliCount === 1 && primaryCliName ? t(cliActivitySummaryKey(primaryCliStatus, isTurnStreaming), { name: primaryCliName, defaultValue: cliActivitySummaryDefault(primaryCliStatus, isTurnStreaming), }) : t(cliActivityManySummaryKey(cliRuns, isTurnStreaming), { count: cliCount, defaultValue: cliActivityManySummaryDefault(cliRuns, isTurnStreaming), }) : ""; const summary = fileCount > 0 ? fileActivitySummary : cliCount > 0 ? cliActivitySummary : isTurnStreaming ? reasoningSteps > 0 ? t("message.agentActivityLiveSummary", { reasoning: reasoningSteps, tools: toolCalls, defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls", }) : toolCalls === 0 && fileCount > 0 ? t("message.agentActivityLiveFilesOnly", { defaultValue: "Working…" }) : t("message.agentActivityLiveToolsOnly", { tools: toolCalls, defaultValue: "Working… · {{tools}} tool calls", }) : reasoningSteps > 0 ? t("message.agentActivitySummary", { reasoning: reasoningSteps, tools: toolCalls, defaultValue: "{{reasoning}} steps · {{tools}} tool calls", }) : toolCalls === 0 && fileCount > 0 ? t("message.agentActivityFilesOnly", { defaultValue: "File changes" }) : t("message.agentActivityToolsOnly", { tools: toolCalls, defaultValue: "{{tools}} tool calls", }); const cancelActivityScrollFrame = useCallback(() => { if (scrollFrameRef.current !== null) { window.cancelAnimationFrame(scrollFrameRef.current); scrollFrameRef.current = null; } }, []); const scrollActivityToBottom = useCallback(() => { const el = activityScrollRef.current; if (!el) return; el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight); }, []); const scheduleActivityScrollToBottom = useCallback(() => { cancelActivityScrollFrame(); scrollFrameRef.current = window.requestAnimationFrame(() => { scrollFrameRef.current = null; scrollActivityToBottom(); }); }, [cancelActivityScrollFrame, scrollActivityToBottom]); const toggleOuter = () => { const nextOpen = userToggledOuter ? !outerOpenLocal : !outerExpanded; if (nextOpen) { autoFollowActivityRef.current = true; } setUserToggledOuter(true); setOuterOpenLocal(nextOpen); }; useLayoutEffect(() => { if (!outerExpanded || !autoFollowActivityRef.current) return; scheduleActivityScrollToBottom(); }, [outerExpanded, messages, isTurnStreaming, scheduleActivityScrollToBottom]); useEffect(() => { if (!outerExpanded) { autoFollowActivityRef.current = true; return; } const target = activityContentRef.current; if (!target || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { if (autoFollowActivityRef.current) { scheduleActivityScrollToBottom(); } }); observer.observe(target); return () => observer.disconnect(); }, [outerExpanded, scheduleActivityScrollToBottom]); useEffect(() => cancelActivityScrollFrame, [cancelActivityScrollFrame]); const onActivityScroll = useCallback(() => { const el = activityScrollRef.current; if (!el) return; const distance = el.scrollHeight - el.scrollTop - el.clientHeight; autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX; }, []); if (!hasVisibleActivity) return null; const HeaderIcon = cliCount > 0 && fileCount === 0 && toolCalls === 0 ? Terminal : Layers; return (
{outerExpanded && (
{messages.map((m) => { if (isReasoningOnlyAssistant(m)) { return ( ); } if (m.kind === "trace") { const normalLines = traceLines(m).filter((line) => !parseCliRunTrace(line)); return normalLines.length > 0 ? (
) : null; } return null; })} {cliRuns.length ? ( ) : null} {fileEdits.length ? : null}
)}
); } function shortFileName(path: string): string { return path.split(/[\\/]/).pop() || path; } function traceLines(message: UIMessage): string[] { if (message.traces?.length) return message.traces; return message.content.trim() ? [message.content] : []; } const CLI_RUN_TOOL_NAMES = new Set(["run_cli_app", "cli_anything_run"]); const CLI_RUN_STATUS_RANK: Record = { running: 1, done: 2, error: 3 }; function isCliRunTraceLine(line: string): boolean { return /^(run_cli_app|cli_anything_run)\(/.test(line.trim()); } function parseCliRunTrace(line: string, status: CliRunStatus = "running"): CliRunSummary | null { const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim()); if (!match) return null; const argsText = match[2].trim(); let argsObject: unknown = {}; if (argsText) { try { argsObject = JSON.parse(argsText); } catch { return { key: line, name: "cli", args: [argsText], json: false, status, }; } } return cliRunFromArguments(argsObject, { key: line, status }); } function parseToolEventArguments(event: ToolProgressEvent): unknown { const fnArgs = (event as { function?: { arguments?: unknown } }).function?.arguments; const raw = fnArgs ?? event.arguments; if (typeof raw !== "string") return raw ?? {}; if (!raw.trim()) return {}; try { return JSON.parse(raw); } catch { return { args: [raw] }; } } function cliRunStatusFromPhase(phase: unknown): CliRunStatus { if (phase === "error") return "error"; if (phase === "end") return "done"; return "running"; } function cliRunError(event: ToolProgressEvent): string | undefined { const error = event.error; if (typeof error === "string") return error; if (error && typeof error === "object") return JSON.stringify(error); return undefined; } function cliRunFromArguments( argsObject: unknown, options: { key: string; status: CliRunStatus; error?: string }, ): CliRunSummary { if (!argsObject || typeof argsObject !== "object" || Array.isArray(argsObject)) { return { key: options.key, name: "cli", args: [], json: false, status: options.status, error: options.error, }; } const record = argsObject as Record; const appName = typeof record.name === "string" && record.name.trim() ? record.name.trim() : "cli"; const rawArgs = Array.isArray(record.args) ? record.args : []; const cliArgs = rawArgs.filter((item): item is string => typeof item === "string"); return { key: options.key, name: appName, args: cliArgs, json: record.json === true || record.json === "true", workingDir: typeof record.working_dir === "string" ? record.working_dir : undefined, status: options.status, error: options.error, }; } function cliRunFromEvent(event: ToolProgressEvent): CliRunSummary | null { const name = typeof (event as { function?: { name?: unknown } }).function?.name === "string" ? String((event as { function?: { name?: unknown } }).function?.name) : typeof event.name === "string" ? event.name : ""; if (!CLI_RUN_TOOL_NAMES.has(name)) return null; const argsObject = parseToolEventArguments(event); const key = event.call_id ? `call:${event.call_id}` : `${name}:${JSON.stringify(argsObject)}`; return cliRunFromArguments(argsObject, { key, status: cliRunStatusFromPhase(event.phase), error: cliRunError(event), }); } function mergeCliRun(existing: CliRunSummary | undefined, incoming: CliRunSummary): CliRunSummary { if (!existing) return incoming; return CLI_RUN_STATUS_RANK[incoming.status] >= CLI_RUN_STATUS_RANK[existing.status] ? { ...existing, ...incoming } : existing; } function collectCliRuns(messages: UIMessage[]): CliRunSummary[] { const runsByKey = new Map(); for (const message of messages) { if (message.kind !== "trace") continue; let hasStructuredCliRun = false; for (const event of message.toolEvents ?? []) { const run = cliRunFromEvent(event); if (!run) continue; hasStructuredCliRun = true; runsByKey.set(run.key, mergeCliRun(runsByKey.get(run.key), run)); } if (hasStructuredCliRun) continue; for (const line of traceLines(message)) { const run = parseCliRunTrace(line); if (!run || runsByKey.has(run.key)) continue; runsByKey.set(run.key, run); } } return [...runsByKey.values()]; } function displayCliArg(arg: string): string { return /\s/.test(arg) ? JSON.stringify(arg) : arg; } function formatCliArgs(run: CliRunSummary): string { const args = [...(run.json ? ["--json"] : []), ...run.args].map(displayCliArg); return args.join(" "); } function cliActivitySummaryKey(status: CliRunStatus | undefined, active: boolean): string { if (status === "error") return "message.cliActivityFailedOne"; return active && status === "running" ? "message.cliActivityRunningOne" : "message.cliActivityRanOne"; } function cliActivitySummaryDefault(status: CliRunStatus | undefined, active: boolean): string { if (status === "error") return "CLI failed @{{name}}"; return `${active && status === "running" ? "Running" : "Ran"} CLI @{{name}}`; } function cliActivityManySummaryKey(runs: CliRunSummary[], active: boolean): string { if (runs.some((run) => run.status === "error")) return "message.cliActivityFailedMany"; return active && runs.some((run) => run.status === "running") ? "message.cliActivityRunningMany" : "message.cliActivityRanMany"; } function cliActivityManySummaryDefault(runs: CliRunSummary[], active: boolean): string { if (runs.some((run) => run.status === "error")) return "{{count}} CLI failed"; return `${active && runs.some((run) => run.status === "running") ? "Running" : "Ran"} {{count}} CLIs`; } function cliRunLabelKey(run: CliRunSummary, active: boolean): string { if (run.status === "error") return "message.cliRunFailed"; return active && run.status === "running" ? "message.cliRunRunning" : "message.cliRunRan"; } function cliRunLabelDefault(run: CliRunSummary, active: boolean): string { if (run.status === "error") return "CLI failed"; return active && run.status === "running" ? "Running CLI" : "Ran CLI"; } function fileActivityVerb(editing: boolean, failed: boolean): string { if (failed) return "Failed"; return editing ? "Editing" : "Edited"; } function fileActivitySummaryKey(editing: boolean, failed: boolean): string { if (failed) return "message.fileActivityFailedOne"; return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne"; } function fileActivityManySummaryKey(editing: boolean, failed: boolean): string { if (failed) return "message.fileActivityFailedMany"; return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany"; } function fileEditCallKey(edit: UIFileEdit): string { if (edit.call_id) return `${edit.call_id}|${edit.tool}`; return `${edit.tool}|${edit.path}`; } function collectFileEdits(messages: UIMessage[]): UIFileEdit[] { const edits: UIFileEdit[] = []; for (const message of messages) { if (message.kind === "trace" && message.fileEdits?.length) { edits.push(...message.fileEdits); } } return edits; } function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] { const order: string[] = []; const byKey = new Map(); for (const edit of edits) { const key = fileEditCallKey(edit); if (!byKey.has(key)) order.push(key); byKey.set(key, edit); } return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[]; } function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] { interface MutableSummary { key: string; path: string; absolute_path?: string; added: number; deleted: number; approximate: boolean; binary: boolean; pending: boolean; hasSuccessfulChange: boolean; hasActiveEditing: boolean; hasFailed: boolean; error?: string; } const order: string[] = []; const byPath = new Map(); for (const edit of latestFileEditEvents(edits)) { const key = edit.path || edit.call_id || edit.tool; let summary = byPath.get(key); if (!summary) { summary = { key, path: edit.path || "", absolute_path: edit.absolute_path, added: 0, deleted: 0, approximate: false, binary: false, pending: false, hasSuccessfulChange: false, hasActiveEditing: false, hasFailed: false, }; byPath.set(key, summary); order.push(key); } if (edit.path && !summary.path) { summary.path = edit.path; } if (edit.absolute_path) { summary.absolute_path = edit.absolute_path; } summary.pending = summary.pending || !!edit.pending || !edit.path; if (!edit.path && edit.pending) { if (active && edit.status === "editing") { summary.hasActiveEditing = true; summary.approximate = summary.approximate || !!edit.approximate; if (!edit.binary) { summary.added += edit.added; summary.deleted += edit.deleted; } } continue; } if (active && edit.status === "editing") { summary.hasActiveEditing = true; summary.binary = summary.binary || !!edit.binary; summary.approximate = summary.approximate || !!edit.approximate; if (!edit.binary) { summary.added += edit.added; summary.deleted += edit.deleted; } continue; } if (edit.status === "error") { summary.hasFailed = true; summary.error = edit.error ?? summary.error; continue; } summary.hasSuccessfulChange = true; summary.binary = summary.binary || !!edit.binary; summary.approximate = active && (summary.approximate || !!edit.approximate); if (!edit.binary) { summary.added += edit.added; summary.deleted += edit.deleted; } } return order.flatMap((key) => { const summary = byPath.get(key)!; if ( !summary.path && !summary.hasActiveEditing && !summary.hasSuccessfulChange && !summary.hasFailed ) { return []; } const status: UIFileEdit["status"] = summary.hasActiveEditing ? "editing" : summary.hasSuccessfulChange ? "done" : summary.hasFailed ? "error" : "done"; return [{ key: summary.key, path: summary.path, absolute_path: summary.absolute_path, added: summary.added, deleted: summary.deleted, approximate: summary.approximate, binary: summary.binary, status, pending: summary.pending && !summary.path, error: summary.error, }]; }); } function hasVisibleDiffStats(edit: Pick): boolean { return edit.added > 0 || edit.deleted > 0; } function CliRunGroup({ runs, active, cliAppsByName, }: { runs: CliRunSummary[]; active: boolean; cliAppsByName: Map; }) { if (runs.length === 0) return null; return (
    {runs.map((run) => ( ))}
); } function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; app?: CliAppInfo }) { const { t } = useTranslation(); const [logoFailed, setLogoFailed] = useState(false); const args = formatCliArgs(run); const failed = run.status === "error"; const rowActive = active && run.status === "running"; const color = failed ? "#DC2626" : app?.brand_color || "#0891B2"; const logoUrl = app?.logo_url && !logoFailed ? app.logo_url : null; return (
  • {logoUrl ? ( setLogoFailed(true)} /> ) : app ? ( cliAppInitials(app).slice(0, 2) ) : ( )} {t(cliRunLabelKey(run, active), { defaultValue: cliRunLabelDefault(run, active), })} @{run.name} {failed ? ( ) : null} {args ? ( {args} ) : null} {run.error ? ( {run.error} ) : null} {run.workingDir ? ( {run.workingDir} ) : null}
  • ); } function alphaColor(color: string, percent: number): string { if (/^#[0-9a-f]{6}$/i.test(color)) { const alpha = Math.round((percent / 100) * 255) .toString(16) .padStart(2, "0"); return `${color}${alpha}`; } return `color-mix(in srgb, ${color} ${percent}%, transparent)`; } function FileEditGroup({ edits }: { edits: FileEditSummary[] }) { if (edits.length === 0) return null; return (
      {edits.map((edit) => ( ))}
    ); } function FileEditRow({ edit }: { edit: FileEditSummary }) { const { t } = useTranslation(); const editing = edit.status === "editing"; const failed = edit.status === "error"; const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit); return (
  • {edit.pending && !edit.path ? ( {t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })} ) : ( )} {failed ? ( {t("message.fileEditFailed", { defaultValue: "Failed" })} ) : null} {edit.approximate && !failed ? ( {t("message.fileEditApproximate", { defaultValue: "estimated" })} ) : null}
    {hasCountedDiff ? ( ) : null}
  • ); } function DiffPair({ added, deleted }: { added: number; deleted: number }) { return ( ); } function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) { const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; return ( {sign} {sign}{safeValue} ); } function AnimatedNumber({ value }: { value: number }) { const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; const [display, setDisplay] = useState(0); const displayRef = useRef(0); const setAnimatedDisplay = useCallback((next: number) => { displayRef.current = next; setDisplay(next); }, []); useEffect(() => { const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; if (reduceMotion) { setAnimatedDisplay(safeValue); return; } const start = displayRef.current; const delta = safeValue - start; if (delta === 0) { setAnimatedDisplay(safeValue); return; } const duration = 260; const startedAt = performance.now(); let frame = 0; const tick = (now: number) => { const progress = Math.min(1, (now - startedAt) / duration); const eased = 1 - Math.pow(1 - progress, 3); setAnimatedDisplay(Math.round(start + delta * eased)); if (progress < 1) { frame = window.requestAnimationFrame(tick); return; } displayRef.current = safeValue; }; frame = window.requestAnimationFrame(tick); return () => window.cancelAnimationFrame(frame); }, [safeValue, setAnimatedDisplay]); return ; } function RollingNumber({ value }: { value: number }) { const digits = String(value).split(""); return ( {digits.map((digit, index) => ( ))} ); } function RollingDigit({ digit }: { digit: number }) { const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0; return ( {Array.from({ length: 10 }, (_, n) => ( {n} ))} ); }