import type { AgentLogEntry } from "@fusion/core"; import { ProviderIcon } from "./ProviderIcon"; import { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo, useId, type ReactElement } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Components } from "react-markdown"; import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react"; import "./AgentLogViewer.css"; function formatTimestamp(iso: string): string { const date = new Date(iso); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMin = Math.floor(diffMs / 60000); const diffHr = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHr / 24); if (diffMin < 1) return "just now"; if (diffMin < 60) return `${diffMin}m ago`; if (diffHr < 24) return `${diffHr}h ago`; if (diffDay < 7) return `${diffDay}d ago`; return date.toLocaleDateString(); } const markdownComponents: Components = { pre: ({ children, ...props }) => (
      {children}
    
), table: ({ children, ...props }) => ( {children}
), }; const BOTTOM_FOLLOW_THRESHOLD_PX = 50; const AGENT_DISPLAY_NAMES: Record = { triage: "Plan", }; function isNearBottom(container: HTMLDivElement): boolean { return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX; } function getEntrySignature(entry: AgentLogEntry): string { return [ entry.taskId, entry.timestamp, entry.agent ?? "", entry.type, entry.text, entry.detail ?? "", ].join("|"); } function buildEntryRenderKeys(entries: AgentLogEntry[]): string[] { const countsBySignature = new Map(); return entries.map((entry) => { const signature = getEntrySignature(entry); const occurrence = countsBySignature.get(signature) ?? 0; countsBySignature.set(signature, occurrence + 1); return `${signature}|${occurrence}`; }); } function isToolLikeType(type: AgentLogEntry["type"]): boolean { return type === "tool" || type === "tool_result" || type === "tool_error"; } interface CollapsibleToolDetailProps { detail: string; type?: "tool" | "tool_result" | "tool_error"; } function CollapsibleToolDetail({ detail }: CollapsibleToolDetailProps): ReactElement { const [expanded, setExpanded] = useState(false); const contentId = useId(); const lineCount = detail.split("\n").length; const toggleLabel = expanded ? "Hide output" : `Show output${lineCount > 1 ? ` (${lineCount} lines)` : ""}`; return (
{detail}
); } function shouldShowBadge(entry: AgentLogEntry, previousEntry?: AgentLogEntry): boolean { if (!entry.agent) return false; if (isToolLikeType(entry.type)) return true; return !previousEntry || previousEntry.agent !== entry.agent || previousEntry.type !== entry.type; } type AgentLogRenderGroup = | { kind: "single"; entry: AgentLogEntry; key: string; showBadge: boolean; } | { kind: "text" | "thinking"; entries: AgentLogEntry[]; key: string; showBadge: boolean; }; function buildRenderGroups(entries: AgentLogEntry[], entryKeys: string[]): AgentLogRenderGroup[] { const groups: AgentLogRenderGroup[] = []; for (let i = 0; i < entries.length; i += 1) { const entry = entries[i]; const rowKey = entryKeys[i] ?? `${getEntrySignature(entry)}|fallback`; const previousEntry = i > 0 ? entries[i - 1] : undefined; const showBadge = shouldShowBadge(entry, previousEntry); if (entry.type === "text" || entry.type === "thinking") { const groupedEntries: AgentLogEntry[] = [entry]; let j = i + 1; while (j < entries.length) { const nextEntry = entries[j]; if (nextEntry.type !== entry.type || nextEntry.agent !== entry.agent) { break; } groupedEntries.push(nextEntry); j += 1; } const endKey = entryKeys[j - 1] ?? `${getEntrySignature(entries[j - 1])}|fallback`; groups.push({ kind: entry.type, entries: groupedEntries, key: `${rowKey}->${endKey}`, showBadge, }); i = j - 1; continue; } groups.push({ kind: "single", entry, key: rowKey, showBadge, }); } return groups; } interface ModelInfo { provider?: string; modelId?: string; } interface AgentLogViewerProps { entries: AgentLogEntry[]; loading: boolean; executorModel?: ModelInfo | null; validatorModel?: ModelInfo | null; planningModel?: ModelInfo | null; /** Whether more entries exist beyond what's currently loaded */ hasMore?: boolean; /** Callback to load older entries */ onLoadMore?: () => void; /** Whether a load more request is in progress */ loadingMore?: boolean; /** Total number of entries (when known) for "Showing X of Y" summary */ totalCount?: number | null; } /** * Renders agent log entries in a scrollable, monospace container. * * Features: * - Displays entries in chronological order (oldest first, newest last) * - Coalesces consecutive same-agent `text`/`thinking` chunks into continuous groups * - Auto-scrolls to keep latest entries visible when streaming * - Supports toggling between markdown-formatted and plain-text rendering * - "Load More" button to fetch older entries when pagination is enabled * - Shows "Showing X of Y entries" summary when totalCount is provided * * @param entries - Array of log entries (in chronological order, oldest first) * @param loading - Whether initial load is in progress * @param hasMore - Whether more older entries exist beyond the current page * @param onLoadMore - Callback to load older entries * @param loadingMore - Whether a load more request is in progress * @param totalCount - Total number of entries (when known) for summary display */ export function AgentLogViewer({ entries, loading, executorModel, validatorModel, planningModel, hasMore = false, onLoadMore, loadingMore = false, totalCount = null, }: AgentLogViewerProps) { const containerRef = useRef(null); const previousEntryCountRef = useRef(0); const previousScrollHeightRef = useRef(0); const previousOldestEntryKeyRef = useRef(null); const previousNewestEntryKeyRef = useRef(null); const [renderMarkdown, setRenderMarkdown] = useState(true); const [isFullscreen, setIsFullscreen] = useState(false); const [modelHeaderExpanded, setModelHeaderExpanded] = useState(false); const [isFollowing, setIsFollowing] = useState(true); const chronologicalEntryKeys = useMemo( () => buildEntryRenderKeys(entries), [entries], ); const renderGroups = useMemo( () => buildRenderGroups(entries, chronologicalEntryKeys), [entries, chronologicalEntryKeys], ); // Keep live-follow pinned to the bottom when new streamed entries append. // When older history is prepended (load more), preserve viewport position. useLayoutEffect(() => { const container = containerRef.current; if (!container) return; const newEntryCount = entries.length; const previousCount = previousEntryCountRef.current; const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight; const oldestEntryKey = chronologicalEntryKeys[0] ?? null; const newestEntryKey = chronologicalEntryKeys[chronologicalEntryKeys.length - 1] ?? null; const oldestEntryChanged = previousOldestEntryKeyRef.current !== oldestEntryKey; const newestEntryChanged = previousNewestEntryKeyRef.current !== newestEntryKey; if (newEntryCount > previousCount) { if (previousCount === 0) { container.scrollTop = container.scrollHeight; } else { const wasNearBottom = previousScrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX; const appendedLiveEntry = newestEntryChanged && !oldestEntryChanged; const prependedOlderEntries = oldestEntryChanged && !newestEntryChanged; if (appendedLiveEntry && wasNearBottom) { container.scrollTop = container.scrollHeight; } if (prependedOlderEntries) { const heightDelta = container.scrollHeight - previousScrollHeight; if (heightDelta > 0) { container.scrollTop += heightDelta; } } } } previousEntryCountRef.current = newEntryCount; previousScrollHeightRef.current = container.scrollHeight; previousOldestEntryKeyRef.current = oldestEntryKey; previousNewestEntryKeyRef.current = newestEntryKey; setIsFollowing(isNearBottom(container)); }, [entries, chronologicalEntryKeys]); const handleScroll = useCallback(() => { const container = containerRef.current; if (!container) return; setIsFollowing(isNearBottom(container)); }, []); const scrollToLive = useCallback(() => { const container = containerRef.current; if (!container) return; container.scrollTop = container.scrollHeight; setIsFollowing(true); }, []); // Escape key handler to exit fullscreen mode const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === "Escape" && isFullscreen) { setIsFullscreen(false); } }, [isFullscreen]); useEffect(() => { if (isFullscreen) { document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); }; } }, [isFullscreen, handleKeyDown]); const hasExecutorOverride = executorModel?.provider && executorModel?.modelId; const hasValidatorOverride = validatorModel?.provider && validatorModel?.modelId; const hasPlanningOverride = planningModel?.provider && planningModel?.modelId; const modelProviders = useMemo(() => { const providers: Array<{ role: string; provider: string; modelId?: string }> = []; if (hasExecutorOverride) { providers.push({ role: "Executor", provider: executorModel!.provider!, modelId: executorModel!.modelId, }); } if (hasValidatorOverride) { providers.push({ role: "Reviewer", provider: validatorModel!.provider!, modelId: validatorModel!.modelId, }); } if (hasPlanningOverride) { providers.push({ role: "Planning", provider: planningModel!.provider!, modelId: planningModel!.modelId, }); } return providers; }, [ hasExecutorOverride, executorModel, hasValidatorOverride, validatorModel, hasPlanningOverride, planningModel, ]); if (loading && entries.length === 0) { return (
Loading agent logs…
); } if (entries.length === 0) { return (
No agent output yet.
); } return (
{/* Model info header */}
{modelProviders.map((modelProvider) => ( ))}
{/* Markdown render toggle */}
{modelHeaderExpanded && (
Executor: {hasExecutorOverride ? ( {executorModel.provider}/{executorModel.modelId} ) : ( Using default )}
Reviewer: {hasValidatorOverride ? ( {validatorModel.provider}/{validatorModel.modelId} ) : ( Using default )}
Planning: {hasPlanningOverride ? ( {planningModel.provider}/{planningModel.modelId} ) : ( Using default )}
)}
{/* Pagination summary */} {totalCount !== null && (
Showing {entries.length} of {totalCount} entries
)} {hasMore && onLoadMore && (
)} {renderGroups.map((group) => { const firstEntry = group.kind === "single" ? group.entry : group.entries[0]; const timestampSpan = group.showBadge ? ( {formatTimestamp(firstEntry.timestamp)} ) : null; const agentBadge = group.showBadge ? ( [{AGENT_DISPLAY_NAMES[firstEntry.agent!] ?? firstEntry.agent}] {timestampSpan} ) : null; if (group.kind === "single") { const { entry } = group; if (entry.type === "tool") { return (
{agentBadge}
⚡ {entry.text}
{entry.detail ? : null}
); } if (entry.type === "tool_result") { return (
{agentBadge}
✓ {entry.text}
{entry.detail ? : null}
); } if (entry.type === "tool_error") { return (
{agentBadge}
✗ {entry.text}
{entry.detail ? : null}
); } } const groupedText = group.kind === "single" ? firstEntry.text : group.entries.map((entry) => entry.text).join(""); if (group.kind === "thinking") { return (
{agentBadge} {renderMarkdown ? (
{groupedText}
) : (
{groupedText}
)}
); } return (
{agentBadge} {renderMarkdown ? (
{groupedText}
) : (
{groupedText}
)}
); })} {!isFollowing && ( )}
); }