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 && (
)}
>
) : (
// 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 && (
)}
>
)}
)}
);
}
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;