import { AgentMessage, AgentMessageType, BatchProgressDetails, Plan } from "@vertesia/common"; import React, { useEffect, useMemo, useState, useRef, useCallback, Component, ReactNode } from "react"; import { cn } from "@vertesia/ui/core"; import { useUITranslation } from '../../../../i18n/index.js'; import { i18nInstance, NAMESPACE } from '../../../../i18n/instance.js'; import { PulsatingCircle } from "../AnimatedThinkingDots"; export type AgentConversationViewMode = "stacked" | "sliding"; import BatchProgressPanel, { type BatchProgressPanelClassNames } from "./BatchProgressPanel"; import MessageItem, { type MessageItemClassNames, type MessageItemProps } from "./MessageItem"; import StreamingMessage, { type StreamingMessageClassNames } from "./StreamingMessage"; import ToolCallGroup, { type ToolCallGroupClassNames } from "./ToolCallGroup"; import WorkstreamTabs, { extractWorkstreams, filterMessagesByWorkstream } from "./WorkstreamTabs"; import { DONE_STATES, getSlidingViewMessageBuckets, getWorkstreamId, groupMessagesWithStreaming, mergeConsecutiveToolGroups, RenderableGroup, shouldCollapseAdjacentRenderedMessage, StreamingData } from "./utils"; import { ThinkingMessages } from "../WaitingMessages"; /** Extended group that may carry preamble info (text from a preceding single/streaming message) */ type RenderableGroupWithPreamble = RenderableGroup & { preambleText?: string; preambleMessage?: AgentMessage; /** When true, this group was consumed as a preamble and should not render */ _consumed?: boolean; }; /** Message types that must never be consumed as preamble text */ const NON_PREAMBLE_TYPES = new Set([ AgentMessageType.QUESTION, AgentMessageType.COMPLETE, AgentMessageType.IDLE, AgentMessageType.TERMINATED, AgentMessageType.ERROR, AgentMessageType.REQUEST_INPUT, AgentMessageType.BATCH_PROGRESS, ]); /** * Scan grouped messages and attach preamble text to tool_groups. * When a single message (THOUGHT, UPDATE, ANSWER, etc.) immediately precedes * a tool_group, the text is attached as preamble and the single message is marked * as consumed so it doesn't render as a separate "Agent" box. */ function attachPreambles(groups: RenderableGroup[]): RenderableGroupWithPreamble[] { const result: RenderableGroupWithPreamble[] = groups.map(g => ({ ...g })); for (let i = 1; i < result.length; i++) { const current = result[i]; const prev = result[i - 1]; // Only attach preamble to tool_groups if (current.type !== 'tool_group') continue; // Previous must be a single message with text content if (prev.type !== 'single' || prev._consumed) continue; const msg = prev.message; const text = typeof msg.message === 'string' ? msg.message.trim() : ''; if (!text) continue; // Skip messages that are tool activity themselves (already part of tool groups) const isToolActivity = msg.details?.tool || msg.details?.tools; if (isToolActivity) continue; // Skip terminal/interactive message types that should always render independently if (NON_PREAMBLE_TYPES.has(msg.type)) continue; // Attach as preamble current.preambleText = text; current.preambleMessage = msg; prev._consumed = true; } // Filter out consumed groups return result.filter(g => !g._consumed); } // Replace %thinking_message% placeholder with actual thinking message const processThinkingPlaceholder = (text: string, thinkingMessageIndex: number): string => { if (text.includes('%thinking_message%')) { return text.replace(/%thinking_message%/g, ThinkingMessages[thinkingMessageIndex]); } return text; }; // Check if message is a batch progress message const isBatchProgressMessage = (message: AgentMessage): message is AgentMessage & { details: BatchProgressDetails } => { return message.type === AgentMessageType.BATCH_PROGRESS && !!message.details?.batch_id; }; // Error boundary to catch and isolate errors in individual message components // Note: Markdown parsing errors are handled internally by MarkdownRenderer, // so this mainly catches other component errors (e.g., artifact loading, charts) class MessageErrorBoundary extends Component< { children: ReactNode }, { hasError: boolean; error?: Error } > { constructor(props: { children: ReactNode }) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error: Error) { console.error('Message render error:', error); return { hasError: true, error }; } componentDidUpdate(prevProps: { children: ReactNode }) { // Auto-reset error state when children change // This allows recovery from transient errors during streaming if (this.state.hasError && prevProps.children !== this.props.children) { this.setState({ hasError: false, error: undefined }); } } render() { if (this.state.hasError) { return (

{i18nInstance.getFixedT(null, NAMESPACE)('agent.failedToRenderMessage')}

{this.state.error?.message || 'Unknown error'}

); } return this.props.children; } } interface AllMessagesMixedProps { messages: AgentMessage[]; bottomRef: React.RefObject; viewMode?: 'stacked' | 'sliding'; isCompleted?: boolean; plan?: Plan; workstreamStatus?: Map; showPlanPanel?: boolean; onTogglePlanPanel?: () => void; plans?: Array<{ plan: Plan, timestamp: number }>; activePlanIndex?: number; onChangePlan?: (index: number) => void; taskLabels?: Map; // Maps task IDs to more descriptive labels streamingMessages?: Map; // Real-time streaming chunks /** Callback when user sends a message (e.g., from proposal selection) */ onSendMessage?: (message: string) => void; /** Stable index for thinking messages (changes on 4s interval) */ thinkingMessageIndex?: number; /** className overrides passed to every MessageItem */ messageItemClassNames?: MessageItemClassNames; /** Sparse MESSAGE_STYLES overrides passed to every MessageItem */ messageStyleOverrides?: MessageItemProps['messageStyleOverrides']; toolCallGroupClassNames?: ToolCallGroupClassNames; /** Hide ToolCallGroup in this view mode */ hideToolCallsInViewMode?: AgentConversationViewMode[]; streamingMessageClassNames?: StreamingMessageClassNames; batchProgressPanelClassNames?: BatchProgressPanelClassNames; /** Run ID used to resolve artifact references in streaming chart specs */ artifactRunId?: string; /** Hide the workstream tabs entirely */ hideWorkstreamTabs?: boolean; /** className override for the working indicator container */ workingIndicatorClassName?: string; /** className override for the message list container (spacing/layout) */ messageListClassName?: string; /** Custom component to render store/document links instead of default NavLink navigation */ StoreLinkComponent?: React.ComponentType<{ href: string; documentId: string; children: React.ReactNode }>; /** Custom component to render store/collection links instead of default NavLink navigation */ CollectionLinkComponent?: React.ComponentType<{ href: string; collectionId: string; children: React.ReactNode }>; /** Optional message to display as the first user message in the conversation. * Purely visual/UI — not sent to temporal. Renders as a QUESTION MessageItem before real messages. */ prependFriendlyMessage?: string; /** Message types to exclude from the conversation view */ hiddenMessageTypes?: AgentMessageType[]; } // PERFORMANCE: Throttle interval for auto-scroll (ms) const SCROLL_THROTTLE_MS = 100; // Max 10 scrolls per second function AllMessagesMixedComponent({ messages, bottomRef, viewMode = 'stacked', isCompleted = false, streamingMessages = new Map(), onSendMessage, thinkingMessageIndex = 0, messageItemClassNames, messageStyleOverrides, toolCallGroupClassNames, hideToolCallsInViewMode, streamingMessageClassNames, batchProgressPanelClassNames, artifactRunId, hideWorkstreamTabs, workingIndicatorClassName, messageListClassName, StoreLinkComponent, CollectionLinkComponent, prependFriendlyMessage, hiddenMessageTypes, }: AllMessagesMixedProps) { if (!artifactRunId) { console.warn('[AllMessagesMixed] artifactRunId prop is missing!'); } const { t } = useUITranslation(); const containerRef = useRef(null); const [activeWorkstream, setActiveWorkstream] = useState("all"); // PERFORMANCE: Throttle auto-scroll to prevent layout thrashing // During streaming, scrollIntoView was being called 30+ times/sec const lastScrollTimeRef = useRef(0); const scrollScheduledRef = useRef(null); // Track whether the user has manually scrolled away from the bottom. // When true, auto-scroll is suppressed so the user can read earlier content. const userScrolledUpRef = useRef(false); // Guard to distinguish programmatic scrolls from user-initiated ones const programmaticScrollRef = useRef(false); const isStreaming = streamingMessages.size > 0; // Detect user scroll: if they scroll away from the bottom, stop auto-scrolling. // Re-enable auto-scroll when they scroll back near the bottom. useEffect(() => { const container = containerRef.current; if (!container) return; const NEAR_BOTTOM_THRESHOLD = 80; // px from bottom to consider "at bottom" const handleScroll = () => { // Ignore scrolls triggered by our own performScroll if (programmaticScrollRef.current) return; const { scrollTop, scrollHeight, clientHeight } = container; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; userScrolledUpRef.current = distanceFromBottom > NEAR_BOTTOM_THRESHOLD; }; container.addEventListener('scroll', handleScroll, { passive: true }); return () => container.removeEventListener('scroll', handleScroll); }, []); // Compute bucketed streaming content length for scroll dependency // Changes every ~200 chars to trigger scroll without excessive updates const streamingContentBucket = useMemo(() => { let total = 0; streamingMessages.forEach((data) => { total += data.text?.length || 0; }); return Math.floor(total / 200); // Bucket by 200 chars }, [streamingMessages]); // Throttled scroll function const performScroll = useCallback(() => { if (bottomRef.current) { programmaticScrollRef.current = true; bottomRef.current.scrollIntoView({ behavior: isStreaming ? "instant" : "smooth" }); lastScrollTimeRef.current = Date.now(); // Reset the programmatic flag after the browser processes the scroll requestAnimationFrame(() => { programmaticScrollRef.current = false; }); } scrollScheduledRef.current = null; }, [bottomRef, isStreaming]); // Auto-scroll to bottom when messages or streaming messages change // Throttled to max 10 scrolls/sec to prevent layout thrashing // Skipped when the user has manually scrolled up to read earlier content useEffect(() => { // Respect user's scroll position — don't yank them back to the bottom if (userScrolledUpRef.current) return; const now = Date.now(); const timeSinceLastScroll = now - lastScrollTimeRef.current; // If we haven't scrolled recently, scroll immediately if (timeSinceLastScroll >= SCROLL_THROTTLE_MS) { performScroll(); } else if (scrollScheduledRef.current === null) { // Schedule a scroll for later if not already scheduled const delay = SCROLL_THROTTLE_MS - timeSinceLastScroll; scrollScheduledRef.current = window.setTimeout(performScroll, delay); } // Cleanup scheduled scroll on unmount or before next effect return () => { if (scrollScheduledRef.current !== null) { clearTimeout(scrollScheduledRef.current); scrollScheduledRef.current = null; } }; }, [messages.length, streamingMessages.size, streamingContentBucket, performScroll]); // Sort all messages chronologically and dedupe adjacent identical messages // Low-signal messages are suppressed at the source (server-side) via shouldSuppressLowSignalMessage const sortedMessages = React.useMemo( () => { const filtered = hiddenMessageTypes?.length ? messages.filter(m => !hiddenMessageTypes.includes(m.type)) : messages; const sorted = [...filtered].sort((a, b) => { const timeA = typeof a.timestamp === "number" ? a.timestamp : new Date(a.timestamp).getTime(); const timeB = typeof b.timestamp === "number" ? b.timestamp : new Date(b.timestamp).getTime(); return timeA - timeB; }); const deduped: AgentMessage[] = []; for (const msg of sorted) { const previous = deduped[deduped.length - 1]; if (previous && shouldCollapseAdjacentRenderedMessage(previous, msg)) { continue; } if (previous && shouldDedupeAdjacentCompletedToolMessage(previous, msg)) { continue; } deduped.push(msg); } return deduped; }, [messages, hiddenMessageTypes], ); // Get workstreams from messages - only from message.workstream_id const workstreams = React.useMemo(() => { // Just get the basic workstreams from the messages const extractedWorkstreams = extractWorkstreams(sortedMessages); // We'll keep taskLabels - they might be used for display purposes elsewhere // but we won't use them to create new workstream tabs return extractedWorkstreams; }, [sortedMessages]); // Count messages per workstream const workstreamCounts = React.useMemo(() => { const counts = new Map(); counts.set("all", sortedMessages.length); // Count main messages const mainMessages = filterMessagesByWorkstream(sortedMessages, "main"); counts.set("main", mainMessages.length); // Count other workstreams sortedMessages.forEach((msg) => { const workstreamId = getWorkstreamId(msg); if (workstreamId !== "main") { counts.set(workstreamId, (counts.get(workstreamId) || 0) + 1); } }); return counts; }, [sortedMessages]); // Filter messages based on active workstream const displayMessages = React.useMemo(() => { if (activeWorkstream === "all") { return sortedMessages; } return filterMessagesByWorkstream(sortedMessages, activeWorkstream); }, [sortedMessages, activeWorkstream]); // Pre-compute important messages and recent thinking for sliding view (avoid IIFE in render) const { importantMessages, recentThinking } = React.useMemo( () => getSlidingViewMessageBuckets(displayMessages, isCompleted, streamingMessages.size > 0), [displayMessages, isCompleted, streamingMessages.size], ); // Split streaming messages: // - complete (or stale incomplete) ones are interleaved chronologically // - actively incomplete ones render at the end // This keeps live streaming performant while preventing old incomplete streams // from being pinned forever at the bottom. const { completeStreaming, incompleteStreaming } = React.useMemo(() => { const complete = new Map(); const incomplete: Array<{ id: string; data: StreamingData }> = []; const newestMessageTimestamp = displayMessages.length > 0 ? Math.max(...displayMessages.map(msg => typeof msg.timestamp === "number" ? msg.timestamp : new Date(msg.timestamp).getTime() )) : -Infinity; streamingMessages.forEach((data, id) => { // Filter by workstream if specified if (activeWorkstream && activeWorkstream !== "all") { const streamWorkstream = data.workstreamId || "main"; if (activeWorkstream !== streamWorkstream) return; } // If a newer persisted message exists, this stream is stale and should be // treated as complete for ordering purposes. const isStale = data.startTimestamp <= newestMessageTimestamp; if (isStale) { // Only interleave chronologically when a newer persisted message // already exists — the streaming message is truly historical. complete.set(id, data); } else if (data.text) { // Keep at the bottom (including isComplete streams) until // the persisted ANSWER/THOUGHT arrives and replaces it. incomplete.push({ id, data }); } }); return { completeStreaming: complete, incompleteStreaming: incomplete }; }, [streamingMessages, activeWorkstream, displayMessages]); // Group messages with ONLY complete streaming interleaved for stacked view // Incomplete streaming is rendered separately at the end (avoids re-grouping on every chunk) // Then attach preamble text from preceding reasoning messages to tool_groups const groupedMessages = React.useMemo( () => attachPreambles(mergeConsecutiveToolGroups(groupMessagesWithStreaming(displayMessages, completeStreaming, activeWorkstream))), [displayMessages, completeStreaming, activeWorkstream] ); // Group important messages with ONLY complete streaming interleaved for sliding view const groupedImportantMessages = React.useMemo( () => attachPreambles(mergeConsecutiveToolGroups(groupMessagesWithStreaming(importantMessages, completeStreaming, activeWorkstream))), [importantMessages, completeStreaming, activeWorkstream] ); // Show working indicator when agent is actively processing const isAgentWorking = useMemo(() => { if (isCompleted) return false; // Agent is working if there are streaming messages, recent thinking, or no final answer yet return streamingMessages.size > 0 || recentThinking.length > 0 || !displayMessages.some(msg => msg.type === AgentMessageType.COMPLETE || msg.type === AgentMessageType.IDLE || msg.type === AgentMessageType.TERMINATED ); }, [isCompleted, streamingMessages.size, recentThinking.length, displayMessages]); // Determine completion status for each workstream const workstreamCompletionStatus = useMemo(() => { const statusMap = new Map(); // Group messages by workstream const workstreamMessages = new Map(); sortedMessages.forEach(message => { const workstreamId = getWorkstreamId(message); if (!workstreamMessages.has(workstreamId)) { workstreamMessages.set(workstreamId, []); } workstreamMessages.get(workstreamId)?.push(message); }); // Check if each workstream is completed for (const [workstreamId, msgs] of workstreamMessages.entries()) { if (msgs.length > 0) { const isCompleted = msgs.some(m => { if ([AgentMessageType.COMPLETE, AgentMessageType.IDLE, AgentMessageType.REQUEST_INPUT, AgentMessageType.TERMINATED].includes(m.type)) { return true; } // Workstream completion is sent as UPDATE with workstream_event: 'completed' or status: 'completed'/'canceled' if (m.type === AgentMessageType.UPDATE && m.details) { const d = m.details as Record; return d.workstream_event === 'completed' || d.status === 'completed' || d.status === 'canceled'; } return false; }); statusMap.set(workstreamId, isCompleted); } } return statusMap; }, [sortedMessages]); return (
{/* Global styles for vprose markdown content */} {/* Workstream tabs with completion indicators */}
{displayMessages.length === 0 ? (
{activeWorkstream === "all" ? t('agent.waitingForAgentResponse') : t('agent.noMessagesInWorkstream')}
) : (
{/* Friendly message — rendered outside the messages array to avoid memo issues/triggering autoscroll */} {prependFriendlyMessage && ( )} {/* Show either all messages or just sliding view depending on viewMode */} {viewMode === 'stacked' ? ( // Details view - show ALL messages with streaming interleaved <> {groupedMessages.map((group, groupIndex) => { const isLastGroup = groupIndex === groupedMessages.length - 1; if (group.type === 'tool_group') { // Render grouped tool calls const lastMessage = group.messages[group.messages.length - 1]; const isTerminalToolStatus = group.toolStatus === "completed" || group.toolStatus === "error" || group.toolStatus === "warning"; const isLatest = !isCompleted && isLastGroup && !DONE_STATES.includes(lastMessage.type) && !isTerminalToolStatus; if (hideToolCallsInViewMode?.includes(viewMode)) return null; return ( ); } else if (group.type === 'streaming') { // Render streaming message - no error boundary to avoid interrupting streaming return ( ); } else { // Render single message const message = group.message; const isLatestMessage = !isCompleted && isLastGroup && !DONE_STATES.includes(message.type); // Special handling for batch progress messages if (isBatchProgressMessage(message)) { return ( ); } return ( ); } })} {/* Incomplete streaming - no error boundary to avoid interrupting streaming */} {incompleteStreaming.map(({ id, data }) => ( ))} {/* Working indicator - shows agent is actively processing */} {isAgentWorking && incompleteStreaming.length === 0 && (
{t('agent.working')}
)} ) : ( // Most Important view - main messages + streaming interleaved <> {groupedImportantMessages.map((group, groupIndex) => { const isLastGroup = groupIndex === groupedImportantMessages.length - 1; if (group.type === 'tool_group') { // Render grouped tool calls const lastMessage = group.messages[group.messages.length - 1]; const isTerminalToolStatus = group.toolStatus === "completed" || group.toolStatus === "error" || group.toolStatus === "warning"; const isLatest = !isCompleted && recentThinking.length === 0 && isLastGroup && !DONE_STATES.includes(lastMessage.type) && !isTerminalToolStatus; if (hideToolCallsInViewMode?.includes(viewMode)) return null; return ( ); } else if (group.type === 'streaming') { // Render streaming message - no error boundary to avoid interrupting streaming return ( ); } else { // Render single message const message = group.message; const isLatestMessage = !isCompleted && recentThinking.length === 0 && isLastGroup && !DONE_STATES.includes(message.type); // Special handling for batch progress messages if (isBatchProgressMessage(message)) { return ( ); } return ( ); } })} {/* Recent thinking messages - displayed with streaming reveal */} {recentThinking.map((thinking, idx) => ( ))} {/* Incomplete streaming - no error boundary to avoid interrupting streaming */} {incompleteStreaming.map(({ id, data }) => ( ))} {/* Working indicator - shows agent is actively processing */} {isAgentWorking && recentThinking.length === 0 && incompleteStreaming.length === 0 && (
{t('agent.working')}
)} )}
)}
); } const shouldDedupeAdjacentCompletedToolMessage = (previous: AgentMessage, current: AgentMessage): boolean => { if (previous.type !== current.type) return false; if (previous.message !== current.message) return false; const prevDetails = previous.details as { tool_status?: string } | undefined; const currDetails = current.details as { tool_status?: string } | undefined; if (prevDetails?.tool_status !== "completed" || currDetails?.tool_status !== "completed") return false; const prevTs = typeof previous.timestamp === "number" ? previous.timestamp : new Date(previous.timestamp).getTime(); const currTs = typeof current.timestamp === "number" ? current.timestamp : new Date(current.timestamp).getTime(); return currTs - prevTs < 2000; }; const AllMessagesMixed = React.memo(AllMessagesMixedComponent); export default AllMessagesMixed;