"use client"; import { memo, useEffect, useRef, useState, useCallback, useMemo, type RefObject } from "react"; import ReactMarkdown, { type Options as ReactMarkdownOptions } from "react-markdown"; import rehypeKatex from "rehype-katex"; import { markdownPreviewRemarkPlugins, normalizeDisplayMath, } from "@/lib/markdown"; import { splitFinalAssistantBlocks } from "@/lib/message-display"; import type { AgentMessage, AssistantMessage, TextContent, UserMessage } from "@/lib/types"; import styles from "./ChatMinimap.module.css"; interface Props { messages: AgentMessage[]; streamingMessage: Partial | null; scrollContainer: RefObject; messageRefs: RefObject<(HTMLDivElement | null)[]>; onRevealHistory: () => void; } const MINIMAP_WIDTH = 36; const MAX_NODE_GAP = 50; const MINIMAP_PADDING = 12; const PREVIEW_HIDE_DELAY = 250; const NAVIGATION_ACTIVE_LOCK_MS = 1600; interface AssistantPreview { markdown: string; element: HTMLDivElement | null; } interface TurnInfo { userMessage: UserMessage; assistantPreviews: AssistantPreview[]; scrollTop: number | null; } interface NodeInfo { topRatio: number; targetTurn: TurnInfo; index: number; } function getUserPreview(message: UserMessage): string { if (typeof message.content === "string") return message.content.trim(); return message.content .filter((block): block is TextContent => block.type === "text") .map((block) => block.text) .join("\n") .trim(); } function getAssistantAnswerMarkdown(message: AgentMessage | Partial): string { if (message.role !== "assistant") return ""; const { answerBlocks } = splitFinalAssistantBlocks(message as AssistantMessage); return answerBlocks .filter((block): block is TextContent => block.type === "text") .map((block) => block.text) .join("\n\n") .trim(); } function PreviewHeading({ level, children, headingIndex, onClick, }: { level: 1 | 2 | 3; children: React.ReactNode; headingIndex: number | null; onClick?: (headingIndex: number) => void; }) { return ( ); } interface PreviewAstNode { type?: string; depth?: number; data?: { hProperties?: Record; }; } function remarkPreviewOutline() { return (tree: { children?: PreviewAstNode[] }) => { if (!Array.isArray(tree.children)) return; const headings = tree.children.filter((node) => ( node.type === "heading" && typeof node.depth === "number" && node.depth <= 3 )); if (headings.length > 0) { headings.forEach((node, headingIndex) => { node.data = { ...node.data, hProperties: { ...node.data?.hProperties, "data-preview-heading-index": headingIndex, }, }; }); tree.children = headings; return; } const firstParagraph = tree.children.find((node) => node.type === "paragraph"); tree.children = firstParagraph ? [firstParagraph] : []; }; } const previewRemarkPlugins = [ ...(markdownPreviewRemarkPlugins ?? []), remarkPreviewOutline, ]; const previewRehypePlugins: ReactMarkdownOptions["rehypePlugins"] = [ [rehypeKatex, { throwOnError: false, strict: false }], ]; function getPreviewHeadingIndex(node: unknown): number | null { const properties = (node as { properties?: Record } | undefined)?.properties; const value = properties?.dataPreviewHeadingIndex ?? properties?.["data-preview-heading-index"]; if (typeof value === "number") return value; if (typeof value === "string" && /^\d+$/.test(value)) return Number(value); return null; } export const AssistantOutline = memo(function AssistantOutline({ markdown, onHeadingClick, onAnswerClick, }: { markdown: string; onHeadingClick?: (headingIndex: number) => void; onAnswerClick?: () => void; }) { const normalizedMarkdown = useMemo(() => normalizeDisplayMath(markdown), [markdown]); if (!markdown) return null; return (
{children}, h2: ({ children, node }) => {children}, h3: ({ children, node }) => {children}, h4: () => null, h5: () => null, h6: () => null, p: ({ children }) => ( ), blockquote: () => null, ul: () => null, ol: () => null, pre: () => null, table: () => null, hr: () => null, a: ({ children }) => <>{children}, code: ({ children }) => <>{children}, }} > {normalizedMarkdown}
); }); function createTurnNodes(turns: TurnInfo[]): NodeInfo[] { return turns.map((turn, index) => ({ topRatio: 0, targetTurn: turn, index, })); } interface NodeLayout { nodes: NodeInfo[]; gap: number; fillsHeight: boolean; } function layoutNodes(allNodes: NodeInfo[], minimapHeight: number): NodeLayout { if (allNodes.length === 0) { return { nodes: [], gap: MAX_NODE_GAP, fillsHeight: false }; } const height = Math.max(1, minimapHeight); const usableHeight = Math.max(0, height - MINIMAP_PADDING * 2); if (allNodes.length === 1) { return { nodes: [{ ...allNodes[0], topRatio: MINIMAP_PADDING / height }], gap: MAX_NODE_GAP, fillsHeight: false, }; } const naturalGap = usableHeight / (allNodes.length - 1); const gap = Math.min(MAX_NODE_GAP, naturalGap); return { nodes: allNodes.map((node, index) => ({ ...node, topRatio: (MINIMAP_PADDING + index * gap) / height, })), gap, fillsHeight: naturalGap <= MAX_NODE_GAP, }; } export function ChatMinimap({ messages, streamingMessage, scrollContainer, messageRefs, onRevealHistory, }: Props) { const [visible, setVisible] = useState(false); const [allNodes, setAllNodes] = useState([]); const [activeIndex, setActiveIndex] = useState(null); const [minimapHeight, setMinimapHeight] = useState(600); const [minimapHovered, setMinimapHovered] = useState(false); const [mouseYRatio, setMouseYRatio] = useState(null); const draggingRef = useRef(false); const containerRef = useRef(null); const allNodesRef = useRef([]); const nodeLayoutRef = useRef({ nodes: [], gap: MAX_NODE_GAP, fillsHeight: false, }); const previewBoxRef = useRef(null); const previewItemRefs = useRef(new Map()); const previewHideTimerRef = useRef | null>(null); const activeNodeLockRef = useRef<{ index: number; until: number } | null>(null); const pendingNavigationRef = useRef<{ nodeIndex: number; target: "user" | "assistant" | "heading"; assistantIndex?: number; headingIndex?: number; } | null>(null); const allMessages = useMemo( () => (streamingMessage ? [...messages, streamingMessage] : messages) as (AgentMessage | Partial)[], [messages, streamingMessage], ); const allMessagesRef = useRef(allMessages); allMessagesRef.current = allMessages; const nodeLayout = useMemo( () => layoutNodes(allNodes, minimapHeight), [allNodes, minimapHeight], ); const { nodes: positionedNodes, gap: nodeGap } = nodeLayout; nodeLayoutRef.current = nodeLayout; const lockActiveNode = useCallback((index: number) => { activeNodeLockRef.current = { index, until: Date.now() + NAVIGATION_ACTIVE_LOCK_MS, }; setActiveIndex(index); }, []); const syncActiveNode = useCallback((scrollEl: HTMLDivElement, nextNodes: NodeInfo[]) => { const activeLock = activeNodeLockRef.current; if (activeLock && Date.now() < activeLock.until) { setActiveIndex(activeLock.index); return; } activeNodeLockRef.current = null; const measuredNodes = nextNodes.filter((node) => node.targetTurn.scrollTop !== null); if (measuredNodes.length === 0) { setActiveIndex(null); return; } const focusTop = scrollEl.scrollTop + scrollEl.clientHeight * 0.3; const nextActiveNode = measuredNodes.reduce((bestNode, node) => ( Math.abs((node.targetTurn.scrollTop ?? 0) - focusTop) < Math.abs((bestNode.targetTurn.scrollTop ?? 0) - focusTop) ? node : bestNode ), measuredNodes[0]); setActiveIndex(nextActiveNode.index); }, []); const updateScroll = useCallback(() => { const scrollEl = scrollContainer.current; if (!scrollEl) return; const scrollable = scrollEl.scrollHeight - scrollEl.clientHeight; const currentNodes = allNodesRef.current; setVisible(scrollable > 20); syncActiveNode(scrollEl, currentNodes); }, [scrollContainer, syncActiveNode]); const measureThrottleRef = useRef | null>(null); const measureNodes = useCallback(() => { if (measureThrottleRef.current) return; measureThrottleRef.current = setTimeout(() => { measureThrottleRef.current = null; const scrollEl = scrollContainer.current; const minimapEl = containerRef.current; if (!scrollEl || !minimapEl) return; const refs = messageRefs.current; const containerRect = scrollEl.getBoundingClientRect(); const turns: TurnInfo[] = []; let refIndex = 0; let currentTurn: TurnInfo | null = null; for (const message of allMessagesRef.current) { if (message.role !== "user" && message.role !== "assistant") continue; const element = refs?.[refIndex]; refIndex++; if (message.role === "user") { currentTurn = null; const elementRect = element?.getBoundingClientRect(); currentTurn = { userMessage: message as UserMessage, assistantPreviews: [], scrollTop: elementRect ? elementRect.top - containerRect.top + scrollEl.scrollTop : null, }; turns.push(currentTurn); continue; } if (!currentTurn) continue; const answerMarkdown = getAssistantAnswerMarkdown(message); if (answerMarkdown) { currentTurn.assistantPreviews.push({ markdown: answerMarkdown, element, }); } } const nextNodes = createTurnNodes(turns); setMinimapHeight(minimapEl.clientHeight); allNodesRef.current = nextNodes; setAllNodes(nextNodes); setVisible(scrollEl.scrollHeight - scrollEl.clientHeight > 20); syncActiveNode(scrollEl, nextNodes); const pendingNavigation = pendingNavigationRef.current; const pendingNode = pendingNavigation ? nextNodes[pendingNavigation.nodeIndex] : null; if (pendingNavigation && pendingNode) { const assistant = pendingNavigation.assistantIndex === undefined ? null : pendingNode.targetTurn.assistantPreviews[pendingNavigation.assistantIndex]; let targetTop: number | null = pendingNode.targetTurn.scrollTop; if (pendingNavigation.target === "assistant") { const assistantRect = assistant?.element?.getBoundingClientRect(); targetTop = assistantRect ? assistantRect.top - containerRect.top + scrollEl.scrollTop : null; } else if (pendingNavigation.target === "heading") { const heading = ( pendingNavigation.headingIndex === undefined ? null : assistant?.element ?.querySelectorAll("h1, h2, h3") .item(pendingNavigation.headingIndex) ); const headingRect = heading?.getBoundingClientRect(); targetTop = headingRect ? headingRect.top - containerRect.top + scrollEl.scrollTop : null; } if (targetTop === null) return; pendingNavigationRef.current = null; lockActiveNode(pendingNode.index); const targetOffset = scrollEl.clientHeight * 0.3; scrollEl.scrollTo({ top: Math.max(0, targetTop - targetOffset), behavior: "smooth" }); } }, 150); }, [lockActiveNode, messageRefs, scrollContainer, syncActiveNode]); useEffect(() => { const el = scrollContainer.current; if (!el) return; el.addEventListener("scroll", updateScroll, { passive: true }); return () => el.removeEventListener("scroll", updateScroll); }, [scrollContainer, updateScroll]); useEffect(() => { const el = scrollContainer.current; if (!el) return; const syncLayout = () => { measureNodes(); updateScroll(); }; const ro = new ResizeObserver(syncLayout); ro.observe(el); if (el.firstElementChild) ro.observe(el.firstElementChild); syncLayout(); return () => { ro.disconnect(); if (measureThrottleRef.current) { clearTimeout(measureThrottleRef.current); measureThrottleRef.current = null; } }; }, [measureNodes, scrollContainer, updateScroll]); useEffect(() => { const timeout = setTimeout(() => { measureNodes(); updateScroll(); }, 50); return () => clearTimeout(timeout); }, [messages.length, measureNodes, updateScroll]); const scrollToNode = useCallback((node: NodeInfo, behavior: ScrollBehavior) => { const scrollEl = scrollContainer.current; if (!scrollEl) return; lockActiveNode(node.index); if (node.targetTurn.scrollTop === null) { pendingNavigationRef.current = { nodeIndex: node.index, target: "user" }; onRevealHistory(); return; } const targetTop = Math.max( 0, node.targetTurn.scrollTop - scrollEl.clientHeight * 0.3, ); scrollEl.scrollTo({ top: targetTop, behavior }); }, [lockActiveNode, onRevealHistory, scrollContainer]); const scrollToAssistant = useCallback((node: NodeInfo, assistantIndex: number) => { const scrollEl = scrollContainer.current; if (!scrollEl) return; const assistantElement = node.targetTurn.assistantPreviews[assistantIndex]?.element; if (!assistantElement) { pendingNavigationRef.current = { nodeIndex: node.index, target: "assistant", assistantIndex, }; onRevealHistory(); return; } const containerRect = scrollEl.getBoundingClientRect(); const assistantRect = assistantElement.getBoundingClientRect(); const targetTop = ( assistantRect.top - containerRect.top + scrollEl.scrollTop - scrollEl.clientHeight * 0.3 ); lockActiveNode(node.index); scrollEl.scrollTo({ top: Math.max(0, targetTop), behavior: "smooth" }); }, [lockActiveNode, onRevealHistory, scrollContainer]); const findNearestNode = useCallback((ratio: number): NodeInfo | null => { const { nodes, gap, fillsHeight } = nodeLayoutRef.current; const height = containerRef.current?.clientHeight ?? 0; if (nodes.length === 0 || height <= 0) return null; const pointerY = Math.max(0, Math.min(height, ratio * height)); const firstNodeY = nodes[0].topRatio * height; const rawIndex = gap > 0 ? Math.round((pointerY - firstNodeY) / gap) : 0; const nodeIndex = Math.max(0, Math.min(nodes.length - 1, rawIndex)); const nearestNode = nodes[nodeIndex]; if (!fillsHeight) { const nodeY = nearestNode.topRatio * height; const hitRadius = Math.max(10, gap / 2); if (Math.abs(pointerY - nodeY) > hitRadius) return null; } return nearestNode; }, []); const scrollToHeading = useCallback(( node: NodeInfo, assistantIndex: number, headingIndex: number, ) => { const scrollEl = scrollContainer.current; if (!scrollEl) return; const answerElement = node.targetTurn.assistantPreviews[assistantIndex]?.element; if (!answerElement) { pendingNavigationRef.current = { nodeIndex: node.index, target: "heading", assistantIndex, headingIndex, }; onRevealHistory(); return; } const heading = answerElement.querySelectorAll("h1, h2, h3").item(headingIndex); if (!heading) return; const containerRect = scrollEl.getBoundingClientRect(); const headingRect = heading.getBoundingClientRect(); const targetTop = ( headingRect.top - containerRect.top + scrollEl.scrollTop - scrollEl.clientHeight * 0.3 ); lockActiveNode(node.index); scrollEl.scrollTo({ top: Math.max(0, targetTop), behavior: "smooth" }); }, [lockActiveNode, onRevealHistory, scrollContainer]); const cancelPreviewHide = useCallback(() => { if (!previewHideTimerRef.current) return; clearTimeout(previewHideTimerRef.current); previewHideTimerRef.current = null; }, []); const showPreview = useCallback(() => { cancelPreviewHide(); setMinimapHovered(true); }, [cancelPreviewHide]); const schedulePreviewHide = useCallback(() => { cancelPreviewHide(); previewHideTimerRef.current = setTimeout(() => { previewHideTimerRef.current = null; setMinimapHovered(false); setMouseYRatio(null); }, PREVIEW_HIDE_DELAY); }, [cancelPreviewHide]); useEffect(() => () => cancelPreviewHide(), [cancelPreviewHide]); const handleMouseDown = useCallback((event: React.MouseEvent) => { if (!visible) return; draggingRef.current = true; showPreview(); const rect = event.currentTarget.getBoundingClientRect(); const pointerRatio = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)); setMouseYRatio(pointerRatio); const jumpToPointer = (clientY: number, behavior: ScrollBehavior) => { const ratio = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)); const node = findNearestNode(ratio); if (node) { scrollToNode(node, behavior); } }; jumpToPointer(event.clientY, "smooth"); const onMove = (moveEvent: MouseEvent) => { if (!draggingRef.current) return; jumpToPointer(moveEvent.clientY, "auto"); }; const onUp = () => { draggingRef.current = false; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }, [findNearestNode, scrollToNode, showPreview, visible]); const nearestNode = mouseYRatio === null ? null : findNearestNode(mouseYRatio); const nearestNodeIndex = nearestNode?.index ?? null; useEffect(() => { if (!minimapHovered || nearestNodeIndex === null) return; const previewBox = previewBoxRef.current; const previewItem = previewItemRefs.current.get(nearestNodeIndex); if (!previewBox || !previewItem) return; const targetTop = previewItem.offsetTop - (previewBox.clientHeight - previewItem.offsetHeight) / 2; previewBox.scrollTop = Math.max(0, targetTop); }, [allNodes, minimapHovered, nearestNodeIndex]); if (!visible) return null; const lastNodeTop = positionedNodes.length > 0 ? positionedNodes[positionedNodes.length - 1].topRatio * minimapHeight : MINIMAP_PADDING; const railHeight = Math.max(1, lastNodeTop - MINIMAP_PADDING); return (
{ const rect = event.currentTarget.getBoundingClientRect(); setMouseYRatio((event.clientY - rect.top) / rect.height); }} style={{ width: MINIMAP_WIDTH, flexShrink: 0, position: "relative", cursor: "pointer", userSelect: "none", borderLeft: "1px solid var(--border)", background: "var(--bg-panel)", overflow: "visible", }} >
{positionedNodes.map((node) => { const isNearest = minimapHovered && nearestNode?.index === node.index; const isActive = activeIndex === node.index; return (
); })} {minimapHovered && allNodes.length > 0 && (
event.stopPropagation()} onMouseMove={(event) => event.stopPropagation()} > {allNodes.map((node) => { const isLocated = nearestNodeIndex === node.index; return (
{ if (element) previewItemRefs.current.set(node.index, element); else previewItemRefs.current.delete(node.index); }} className={styles.turn} data-minimap-preview-index={node.index} data-located={isLocated ? "true" : undefined} >
{node.targetTurn.assistantPreviews.map((assistant, assistantIndex) => (
scrollToAssistant(node, assistantIndex)} onHeadingClick={(headingIndex) => ( scrollToHeading(node, assistantIndex, headingIndex) )} />
))}
); })}
)}
); } // Hook to create a stable array of refs for messages export function useMessageRefs(count: number): RefObject<(HTMLDivElement | null)[]> { const refs = useRef<(HTMLDivElement | null)[]>([]); refs.current = Array(count).fill(null).map((_, i) => refs.current[i] ?? null); return refs; }