import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, ChevronRight, Layers } from "lucide-react"; import { useTranslation } from "react-i18next"; import { FileReferenceChip } from "@/components/FileReferenceChip"; import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble"; import { cn } from "@/lib/utils"; import type { 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; fileCount: number; added: number; deleted: number; hasEditingFiles: boolean; hasFailedFiles: boolean; primaryFilePath?: string; primaryFileTooltipPath?: string; } interface FileEditSummary { key: string; path: string; absolute_path?: string; added: number; deleted: number; approximate: boolean; binary: boolean; status: UIFileEdit["status"]; pending: boolean; error?: string; } function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts { let reasoningSteps = 0; let toolCalls = 0; for (const m of messages) { if (isReasoningOnlyAssistant(m)) { reasoningSteps += 1; continue; } if (m.kind === "trace") { const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0); toolCalls += lines; } } let added = 0; let deleted = 0; 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; } added += edit.added; deleted += edit.deleted; } return { reasoningSteps, toolCalls, fileCount: fileEdits.length, added, deleted, hasEditingFiles, hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length, primaryFilePath, primaryFileTooltipPath, }; } interface AgentActivityClusterProps { messages: UIMessage[]; /** True while the session turn is still running (drives “Working…” copy + header sheen). */ isTurnStreaming: boolean; hasBodyBelow: boolean; } /** * 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, }: AgentActivityClusterProps) { const { t } = useTranslation(); const fileEdits = useMemo( () => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming), [messages, isTurnStreaming], ); const { reasoningSteps, toolCalls, fileCount, added, deleted, hasEditingFiles, hasFailedFiles, primaryFilePath, primaryFileTooltipPath, } = countActivity(messages, fileEdits); 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 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 summary = fileCount > 0 ? fileActivitySummary : 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; }, []); return (
{outerExpanded && (
{messages.map((m) => { if (isReasoningOnlyAssistant(m)) { return ( ); } if (m.kind === "trace") { const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0; return hasTraceLines ? (
) : null; } return null; })} {fileEdits.length ? : null}
)}
); } function shortFileName(path: string): string { return path.split(/[\\/]/).pop() || path; } 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 (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.map((key) => { const summary = byPath.get(key)!; 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 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; 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} ))} ); }