import type { AgentLogEntry } from "@fusion/core"; import { ProviderIcon } from "./ProviderIcon"; import { useRef, useEffect, useState, useCallback, useLayoutEffect } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Components } from "react-markdown"; import { Maximize2, Minimize2, Loader2 } from "lucide-react"; 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 TOP_FOLLOW_THRESHOLD_PX = 50; function getEntryKey(entry: AgentLogEntry | undefined): string | null { if (!entry) { return null; } return [entry.timestamp, entry.agent, entry.type, entry.text, entry.detail].join("|"); } 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 reverse chronological order (newest first) * - 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 previousNewestEntryKeyRef = useRef(null); const [renderMarkdown, setRenderMarkdown] = useState(true); const [isFullscreen, setIsFullscreen] = useState(false); // Newest entries render first. When streaming prepends content while the reader is away // from the top, keep the viewport anchored by offsetting scrollTop with the added height. // Near the top, preserve live-follow behavior by snapping back to the latest output. useLayoutEffect(() => { const container = containerRef.current; if (!container) return; const newEntryCount = entries.length; const previousCount = previousEntryCountRef.current; const previousScrollHeight = previousScrollHeightRef.current; const newestEntryKey = getEntryKey(entries[entries.length - 1]); const newestEntryChanged = previousNewestEntryKeyRef.current !== newestEntryKey; // Only adjust scroll for streaming updates (which append to chronological data // and therefore prepend in this reversed viewer). if (newEntryCount > previousCount) { const isNearTop = container.scrollTop <= TOP_FOLLOW_THRESHOLD_PX; if (newestEntryChanged) { if (isNearTop) { container.scrollTop = 0; } else { const heightDelta = container.scrollHeight - previousScrollHeight; if (heightDelta > 0) { container.scrollTop += heightDelta; } } } } previousEntryCountRef.current = newEntryCount; previousScrollHeightRef.current = container.scrollHeight; previousNewestEntryKeyRef.current = newestEntryKey; }, [entries]); // 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]); if (loading && entries.length === 0) { return (
Loading agent logs…
); } if (entries.length === 0) { return (
No agent output yet.
); } // Reverse entries so newest appear first const reversedEntries = [...entries].reverse(); const hasExecutorOverride = executorModel?.provider && executorModel?.modelId; const hasValidatorOverride = validatorModel?.provider && validatorModel?.modelId; const hasPlanningOverride = planningModel?.provider && planningModel?.modelId; return (
{/* Model info header */}
Executor: {hasExecutorOverride ? ( {executorModel.provider}/{executorModel.modelId} ) : ( Using default )}
Validator: {hasValidatorOverride ? ( {validatorModel.provider}/{validatorModel.modelId} ) : ( Using default )}
Planning/Triage: {hasPlanningOverride ? ( {planningModel.provider}/{planningModel.modelId} ) : ( Using default )}
{/* Markdown render toggle */}
{/* Pagination summary */} {totalCount !== null && (
Showing {entries.length} of {totalCount} entries
)} {reversedEntries.map((entry, i) => { // Look at previous entry in reversed array (= next chronologically) for deduplication const prev = reversedEntries[i - 1]; const isBlockLevel = entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error"; const showBadge = entry.agent ? isBlockLevel || i === 0 || (prev && (prev.agent !== entry.agent || prev.type !== entry.type)) : false; const timestampSpan = showBadge ? ( {formatTimestamp(entry.timestamp)} ) : null; const agentBadge = showBadge ? ( [{entry.agent}] {timestampSpan} ) : null; if (entry.type === "tool") { return (
{agentBadge}⚡ {entry.text} {entry.detail && — {entry.detail}}
); } if (entry.type === "thinking") { return ( {agentBadge} {renderMarkdown ? ( {entry.text} ) : ( entry.text )} ); } if (entry.type === "tool_result") { return (
{agentBadge}✓ {entry.text} {entry.detail && — {entry.detail}}
); } if (entry.type === "tool_error") { return (
{agentBadge}✗ {entry.text} {entry.detail && — {entry.detail}}
); } // Default: text entries return ( {agentBadge} {renderMarkdown ? ( {entry.text} ) : ( entry.text )} ); })} {/* Load More button */} {hasMore && onLoadMore && (
)}
); }