import type { AgentLogEntry } from "@fusion/core"; import { ProviderIcon } from "./ProviderIcon"; import React, { 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"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; const MARKDOWN_TOGGLE_STORAGE_KEY = "fn-agent-log-markdown"; const TOOL_OUTPUT_TOGGLE_STORAGE_KEY = "fn-agent-log-tool-output"; function readBooleanPref(key: string, defaultValue: boolean): boolean { if (typeof window === "undefined") return defaultValue; try { const raw = window.localStorage.getItem(key); if (raw === null) return defaultValue; return raw === "true"; } catch { return defaultValue; } } function writeBooleanPref(key: string, value: boolean): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(key, value ? "true" : "false"); } catch { // ignore storage failures (quota, private mode, etc.) } } 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 = { p: ({ children, ...props }) =>

{linkifyReactChildren(children)}

, li: ({ children, ...props }) =>
  • {linkifyReactChildren(children)}
  • , code: ({ children, ...props }) => { const text = typeof children === "string" ? children : React.Children.toArray(children).join(""); const linkedChildren = linkifyFilePaths(text); if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") { return {children}; } return {linkedChildren}; }, 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 (
    {linkifyFilePaths(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; } interface RenderEntry { entry: AgentLogEntry; hiddenToolBoundaryId: number; } type AgentLogRenderGroup = | { kind: "single"; entry: AgentLogEntry; key: string; showBadge: boolean; } | { kind: "text" | "thinking"; entries: AgentLogEntry[]; key: string; showBadge: boolean; }; function buildRenderGroups(renderEntries: RenderEntry[], entryKeys: string[]): AgentLogRenderGroup[] { const groups: AgentLogRenderGroup[] = []; for (let i = 0; i < renderEntries.length; i += 1) { const { entry, hiddenToolBoundaryId } = renderEntries[i]; const rowKey = entryKeys[i] ?? `${getEntrySignature(entry)}|fallback`; const previousRenderEntry = i > 0 ? renderEntries[i - 1] : undefined; const previousEntry = previousRenderEntry?.entry; const showBadge = shouldShowBadge(entry, previousEntry) || (previousRenderEntry !== undefined && previousRenderEntry.hiddenToolBoundaryId !== hiddenToolBoundaryId); if (entry.type === "text" || entry.type === "thinking") { const groupedEntries: AgentLogEntry[] = [entry]; let j = i + 1; while (j < renderEntries.length) { const next = renderEntries[j]; const nextEntry = next.entry; if ( nextEntry.type !== entry.type || nextEntry.agent !== entry.agent || next.hiddenToolBoundaryId !== hiddenToolBoundaryId ) { break; } groupedEntries.push(nextEntry); j += 1; } const endKey = entryKeys[j - 1] ?? `${getEntrySignature(renderEntries[j - 1].entry)}|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(() => readBooleanPref(MARKDOWN_TOGGLE_STORAGE_KEY, true), ); const [showToolOutput, setShowToolOutput] = useState(() => readBooleanPref(TOOL_OUTPUT_TOGGLE_STORAGE_KEY, true), ); const [isFullscreen, setIsFullscreen] = useState(false); const [modelHeaderExpanded, setModelHeaderExpanded] = useState(false); const [isFollowing, setIsFollowing] = useState(true); useEffect(() => { writeBooleanPref(MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown); }, [renderMarkdown]); useEffect(() => { writeBooleanPref(TOOL_OUTPUT_TOGGLE_STORAGE_KEY, showToolOutput); }, [showToolOutput]); const renderEntries = useMemo(() => { if (showToolOutput) { return entries.map((entry) => ({ entry, hiddenToolBoundaryId: 0 })); } const filtered: RenderEntry[] = []; let hiddenToolBoundaryId = 0; for (const entry of entries) { if (isToolLikeType(entry.type)) { hiddenToolBoundaryId += 1; continue; } filtered.push({ entry, hiddenToolBoundaryId }); } return filtered; }, [entries, showToolOutput]); const visibleEntries = useMemo( () => renderEntries.map((renderEntry) => renderEntry.entry), [renderEntries], ); const chronologicalEntryKeys = useMemo( () => buildEntryRenderKeys(visibleEntries), [visibleEntries], ); const renderGroups = useMemo( () => buildRenderGroups(renderEntries, chronologicalEntryKeys), [renderEntries, 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 {visibleEntries.length} of {totalCount} entries {!showToolOutput && entries.length !== visibleEntries.length ? ` (${entries.length - visibleEntries.length} tool entries hidden)` : ""}
    )} {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}
    ) : (
    {linkifyFilePaths(groupedText)}
    )}
    ); } return (
    {agentBadge} {renderMarkdown ? (
    {groupedText}
    ) : (
    {linkifyFilePaths(groupedText)}
    )}
    ); })} {!isFollowing && ( )}
    ); }