import type { Message } from "@langchain/langgraph-sdk"; import { BookOpenTextIcon, ChevronUp, CoinsIcon, FolderOpenIcon, GlobeIcon, LightbulbIcon, ListTodoIcon, MessageCircleQuestionMarkIcon, MessageSquareTextIcon, MonitorIcon, NotebookPenIcon, SearchIcon, SquareTerminalIcon, WrenchIcon, } from "lucide-react"; import { memo, useEffect, useMemo, useState } from "react"; import { ChainOfThought, ChainOfThoughtContent, ChainOfThoughtSearchResult, ChainOfThoughtSearchResults, ChainOfThoughtStep, } from "@/components/ai-elements/chain-of-thought"; import { CodeBlock } from "@/components/ai-elements/code-block"; import { Button } from "@/components/ui/button"; import { buildWriteFileArtifactURL, resolveArtifactURL, } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; import { formatTokenCount } from "@/core/messages/usage"; import type { TokenDebugStep } from "@/core/messages/usage-model"; import { extractContentFromMessage, extractReasoningContentFromMessage, extractTextFromMessage, } from "@/core/messages/utils"; import { extractTitleFromMarkdown } from "@/core/utils/markdown"; import { env } from "@/env"; import { cn } from "@/lib/utils"; import { useArtifacts } from "../artifacts"; import { useMaybeBrowserView } from "../browser-view"; import { FlipDisplay } from "../flip-display"; import { Tooltip } from "../tooltip"; import { MarkdownContent } from "./markdown-content"; import { ToolCallDetails } from "./tool-call-details"; interface MessageGroupProps { className?: string; messages: Message[]; isLoading?: boolean; deferBrowserPreviews?: boolean; tokenDebugSteps?: TokenDebugStep[]; showTokenDebugSummaries?: boolean; threadId?: string; } function MessageGroupComponent({ className, messages, isLoading = false, deferBrowserPreviews = false, tokenDebugSteps = [], showTokenDebugSummaries = false, threadId, }: MessageGroupProps) { const { t } = useI18n(); const [showAbove, setShowAbove] = useState( env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", ); const [showLastThinking, setShowLastThinking] = useState( env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", ); const allSteps = useMemo(() => convertToSteps(messages), [messages]); // Keep the original messages and tool associations intact. Only the display // of clarification context moves outside the execution disclosure (#5503). const clarificationTextSteps = useMemo( () => allSteps.filter( (step): step is CoTAssistantTextStep => step.type === "assistantText" && step.isClarificationContext === true, ), [allSteps], ); const steps = useMemo( () => allSteps.filter( (step) => step.type !== "assistantText" || !step.isClarificationContext, ), [allSteps], ); const stepIndexByStep = useMemo( () => new Map(steps.map((step, index) => [step, index] as const)), [steps], ); const debugStepByMessageId = useMemo( () => new Map( tokenDebugSteps.map( (step) => [step.messageId || step.id, step] as const, ), ), [tokenDebugSteps], ); const toolCallCountByMessageId = useMemo(() => { const counts = new Map(); for (const step of steps) { if (step.type !== "toolCall" || !step.messageId) { continue; } counts.set(step.messageId, (counts.get(step.messageId) ?? 0) + 1); } return counts; }, [steps]); const lastToolCallStep = useMemo(() => { const filteredSteps = steps.filter((step) => step.type === "toolCall"); return filteredSteps[filteredSteps.length - 1]; }, [steps]); const aboveLastToolCallSteps = useMemo(() => { if (lastToolCallStep) { const index = stepIndexByStep.get(lastToolCallStep) ?? -1; return steps.slice(0, index); } return []; }, [lastToolCallStep, stepIndexByStep, steps]); const afterLastToolCallAssistantTextSteps = useMemo(() => { if (!lastToolCallStep) { return []; } const index = stepIndexByStep.get(lastToolCallStep) ?? -1; return steps .slice(index + 1) .filter((step) => step.type === "assistantText"); }, [lastToolCallStep, stepIndexByStep, steps]); const collapsibleAboveLastToolCallSteps = useMemo( () => aboveLastToolCallSteps.filter((step) => step.type !== "assistantText"), [aboveLastToolCallSteps], ); const lastReasoningStep = useMemo(() => { if (lastToolCallStep) { const index = stepIndexByStep.get(lastToolCallStep) ?? -1; return steps.slice(index + 1).find((step) => step.type === "reasoning"); } else { const filteredSteps = steps.filter((step) => step.type === "reasoning"); return filteredSteps[filteredSteps.length - 1]; } }, [lastToolCallStep, stepIndexByStep, steps]); // Assistant text emitted after the trailing reasoning is the answer that // reasoning produced, so it renders below the reasoning disclosure. The // settled assistant bubble always paints reasoning above content, and the // streaming processing group has to agree or the two swap places the moment // the turn ends (#4576). Text emitted before that reasoning keeps its // earlier position. const belowLastReasoningAssistantTextSteps = useMemo(() => { if (!lastReasoningStep) { return []; } const index = stepIndexByStep.get(lastReasoningStep) ?? -1; return steps .slice(index + 1) .filter((step) => step.type === "assistantText"); }, [lastReasoningStep, stepIndexByStep, steps]); const belowLastReasoningSteps = useMemo( () => new Set(belowLastReasoningAssistantTextSteps), [belowLastReasoningAssistantTextSteps], ); const firstEligibleDebugSummaryStepIndexByMessageId = useMemo(() => { const firstIndices = new Map(); if (!showTokenDebugSummaries) { return firstIndices; } for (const [index, step] of steps.entries()) { const messageId = step.messageId; if (!messageId || firstIndices.has(messageId)) { continue; } const debugStep = debugStepByMessageId.get(messageId); if (!debugStep) { continue; } const toolCallCount = toolCallCountByMessageId.get(messageId) ?? 0; if (!debugStep.sharedAttribution && toolCallCount > 0) { continue; } if ( !debugStep.sharedAttribution && toolCallCount === 0 && debugStep.label === t.common.thinking && debugStep.secondaryLabels.length === 0 ) { continue; } firstIndices.set(messageId, index); } return firstIndices; }, [ debugStepByMessageId, showTokenDebugSummaries, steps, t.common.thinking, toolCallCountByMessageId, ]); const renderDebugSummary = ( messageId: string | undefined, stepIndex: number, ) => { if (!showTokenDebugSummaries || !messageId) { return null; } const debugStep = debugStepByMessageId.get(messageId); if (!debugStep) { return null; } if ( firstEligibleDebugSummaryStepIndexByMessageId.get(messageId) !== stepIndex ) { return null; } return ( } description={ debugStep.sharedAttribution ? t.tokenUsage.sharedAttribution : undefined } > {debugStep.secondaryLabels.length > 0 && ( {debugStep.secondaryLabels.map((label, index) => ( {label} ))} )} ); }; const renderToolCall = ( step: CoTToolCallStep, options?: { isLast?: boolean }, ) => { const debugStep = showTokenDebugSummaries && step.messageId ? debugStepByMessageId.get(step.messageId) : undefined; return ( ); }; const renderAssistantText = (step: CoTAssistantTextStep) => ( } > ); const renderStep = (step: CoTStep) => { const stepIndex = stepIndexByStep.get(step) ?? -1; if (step.type === "assistantText") { return [ renderDebugSummary(step.messageId, stepIndex), renderAssistantText(step), ]; } if (step.type === "reasoning") { return [ renderDebugSummary(step.messageId, stepIndex), } >, ]; } return [ renderDebugSummary(step.messageId, stepIndex), renderToolCall(step), ]; }; const lastReasoningDebugStep = showTokenDebugSummaries && lastReasoningStep?.messageId ? debugStepByMessageId.get(lastReasoningStep.messageId) : undefined; const processingPanel = ( {collapsibleAboveLastToolCallSteps.length > 0 && ( )} {(lastToolCallStep ?? steps.some( (step) => step.type === "assistantText" && !belowLastReasoningSteps.has(step), )) && ( {(lastToolCallStep ? showAbove ? aboveLastToolCallSteps : aboveLastToolCallSteps.filter( (step) => step.type === "assistantText", ) : steps.filter( (step) => step.type === "assistantText" && !belowLastReasoningSteps.has(step), ) ).flatMap(renderStep)} {lastToolCallStep && ( <> {renderDebugSummary( lastToolCallStep.messageId, stepIndexByStep.get(lastToolCallStep) ?? -1, )} {renderToolCall(lastToolCallStep, { isLast: true })} {afterLastToolCallAssistantTextSteps .filter((step) => !belowLastReasoningSteps.has(step)) .flatMap(renderStep)} )} )} {lastReasoningStep && ( <> {renderDebugSummary( lastReasoningStep.messageId, stepIndexByStep.get(lastReasoningStep) ?? -1, )} {showLastThinking && ( } > )} {belowLastReasoningAssistantTextSteps.length > 0 && ( {belowLastReasoningAssistantTextSteps.flatMap(renderStep)} )} )} ); return ( <> {processingPanel} {clarificationTextSteps.map((step) => (
))} ); } export const MessageGroup = memo( MessageGroupComponent, areMessageGroupPropsEqual, ); MessageGroup.displayName = "MessageGroup"; function areMessageGroupPropsEqual( previous: MessageGroupProps, next: MessageGroupProps, ): boolean { if (next.isLoading) { return false; } return ( previous.className === next.className && Boolean(previous.isLoading) === Boolean(next.isLoading) && Boolean(previous.deferBrowserPreviews) === Boolean(next.deferBrowserPreviews) && Boolean(previous.showTokenDebugSummaries) === Boolean(next.showTokenDebugSummaries) && previous.threadId === next.threadId && sameReferences(previous.messages, next.messages) && sameReferences(previous.tokenDebugSteps, next.tokenDebugSteps) ); } function sameReferences( previous: readonly T[] | undefined, next: readonly T[] | undefined, ): boolean { if (previous === next) { return true; } const previousItems = previous ?? []; const nextItems = next ?? []; return ( previousItems.length === nextItems.length && previousItems.every((item, index) => item === nextItems[index]) ); } function formatDebugToken( debugStep: TokenDebugStep, t: ReturnType["t"], ) { return debugStep.usage ? `${formatTokenCount(debugStep.usage.totalTokens)} ${t.tokenUsage.label}` : t.tokenUsage.unavailableShort; } function shouldInlineThinkingToken({ debugStep, toolCallCount, enabled, thinkingLabel, t, }: { debugStep?: TokenDebugStep; toolCallCount: number; enabled: boolean; thinkingLabel: string; t: ReturnType["t"]; }) { if ( !enabled || !debugStep || debugStep.sharedAttribution || toolCallCount > 0 || debugStep.label !== thinkingLabel ) { return null; } return formatDebugToken(debugStep, t); } function DebugStepLabel({ label, token, }: { label: React.ReactNode; token?: string | null; }) { return (
{label}
{token ? (
{token}
) : null}
); } function browserToolLabel( name: string, args: Record, t: ReturnType["t"], ): string { switch (name) { case "browser_navigate": return typeof args.url === "string" ? t.toolCalls.browserNavigate(args.url) : t.toolCalls.browserNavigateGeneric; case "browser_click": return t.toolCalls.browserClick; case "browser_type": return t.toolCalls.browserType; case "browser_snapshot": return t.toolCalls.browserSnapshot; case "browser_get_text": return t.toolCalls.browserGetText; case "browser_back": return t.toolCalls.browserBack; case "browser_screenshot": return t.toolCalls.browserScreenshot; case "browser_close": return t.toolCalls.browserClose; default: return t.toolCalls.useTool(name); } } // Shared routing for result conversion and specialized rendering. function getToolCallKind(name: string) { if (name.startsWith("browser_")) return "browser"; switch (name) { case "web_search": case "image_search": case "web_fetch": case "ls": case "read_file": case "write_file": case "str_replace": case "bash": case "ask_clarification": case "write_todos": return name; default: return "generic"; } } function ToolCall({ id, messageId, name, args, result, isLast = false, isLoading = false, deferBrowserPreview = false, tokenDebugStep, showDetails = false, resultMessage, browserView, threadId, }: { id?: string; messageId?: string; name: string; args: Record; result?: string | Record; isLast?: boolean; isLoading?: boolean; deferBrowserPreview?: boolean; tokenDebugStep?: TokenDebugStep; showDetails?: boolean; resultMessage?: Extract; browserView?: BrowserViewMeta; threadId?: string; }) { const { t } = useI18n(); const kind = getToolCallKind(name); const { setOpen, autoOpen, autoSelect, selectedArtifact, select } = useArtifacts(); const browserViewPanel = useMaybeBrowserView(); const tokenLabel = tokenDebugStep ? formatDebugToken(tokenDebugStep, t) : null; const resolveLabel = (fallback: React.ReactNode) => tokenDebugStep ? ( ) : ( fallback ); const writeFilePath = (kind === "write_file" || kind === "str_replace") && typeof args.path === "string" ? args.path : undefined; const writeFileArtifactUrl = writeFilePath ? buildWriteFileArtifactURL({ filepath: writeFilePath, messageId, toolCallId: id, }) : null; const autoOpenArtifactUrl = isLoading && isLast && autoOpen && autoSelect && writeFileArtifactUrl && !result ? writeFileArtifactUrl : null; useEffect(() => { if (!autoOpenArtifactUrl || selectedArtifact === autoOpenArtifactUrl) { return; } const timeout = window.setTimeout(() => { select(autoOpenArtifactUrl, true); setOpen(true); }, 100); return () => window.clearTimeout(timeout); }, [autoOpenArtifactUrl, select, selectedArtifact, setOpen]); if (kind === "browser") { const shot = browserView?.screenshot; const previewUrl = shot && threadId ? resolveArtifactURL(shot, threadId) : undefined; return ( {previewUrl && !deferBrowserPreview && ( )} ); } else if (kind === "web_search") { let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; if (typeof args.query === "string") { label = t.toolCalls.searchOnWebFor(args.query); } return ( {Array.isArray(result) && ( {result.map((item) => ( {item.title} ))} )} ); } else if (kind === "image_search") { let label: React.ReactNode = t.toolCalls.searchForRelatedImages; if (typeof args.query === "string") { label = t.toolCalls.searchForRelatedImagesFor(args.query); } const results = ( result as { results: { source_url: string; thumbnail_url: string; image_url: string; title: string; }[]; } )?.results; return ( {Array.isArray(results) && ( {Array.isArray(results) && results.map((item) => (
{item.title}
))}
)}
); } else if (kind === "web_fetch") { const url = (args as { url: string })?.url; let title = url; if (typeof result === "string") { const potentialTitle = extractTitleFromMarkdown(result); if (potentialTitle && potentialTitle.toLowerCase() !== "untitled") { title = potentialTitle; } } return ( {url && ( {title} )} ); } else if (kind === "ls") { let description: string | undefined = (args as { description: string }) ?.description; if (!description) { description = t.toolCalls.listFolder; } const path: string | undefined = (args as { path: string })?.path; return ( {path && ( {path} )} ); } else if (kind === "read_file") { let description: string | undefined = (args as { description: string }) ?.description; if (!description) { description = t.toolCalls.readFile; } const { path } = args as { path: string; content: string }; return ( {path && ( {path} )} ); } else if (kind === "write_file" || kind === "str_replace") { let description: string | undefined = (args as { description: string }) ?.description; if (!description) { description = t.toolCalls.writeFile; } return ( { if (!writeFileArtifactUrl) { return; } select(writeFileArtifactUrl); setOpen(true); }} > {writeFilePath && ( {writeFilePath} )} ); } else if (kind === "bash") { const description: string | undefined = (args as { description: string }) ?.description; if (!description) { return ( ); } const command: string | undefined = (args as { command: string })?.command; return ( {command && ( )} ); } else if (kind === "ask_clarification") { return ( ); } else if (kind === "write_todos") { return ( ); } else { const description: string | undefined = (args as { description: string }) ?.description; return ( {showDetails && ( )} ); } } interface GenericCoTStep { id?: string; messageId?: string; type: T; } interface CoTReasoningStep extends GenericCoTStep<"reasoning"> { reasoning: string | null; } interface CoTToolCallStep extends GenericCoTStep<"toolCall"> { name: string; args: Record; result?: string; resultMessage?: Extract; browserView?: BrowserViewMeta; } interface CoTAssistantTextStep extends GenericCoTStep<"assistantText"> { isClarificationContext?: boolean; content: string; } type CoTStep = CoTAssistantTextStep | CoTReasoningStep | CoTToolCallStep; interface BrowserViewMeta { screenshot: string; url?: string; title?: string; } function indexToolCallData(messages: Message[]) { const toolCallResults = new Map(); const browserViews = new Map(); const resultMessages = new Map>(); for (const message of messages) { if (message.type !== "tool" || !message.tool_call_id) { continue; } const toolCallId = message.tool_call_id; if (!resultMessages.has(toolCallId)) resultMessages.set(toolCallId, message); if (!toolCallResults.has(toolCallId)) { const result = extractTextFromMessage(message); if (result) { toolCallResults.set(toolCallId, result); resultMessages.set(toolCallId, message); } } if (!browserViews.has(toolCallId)) { const browserView = ( message.additional_kwargs as | { browser_view?: BrowserViewMeta } | undefined )?.browser_view; if (browserView && typeof browserView.screenshot === "string") { browserViews.set(toolCallId, browserView); } } } return { browserViews, toolCallResults, resultMessages }; } function convertToSteps(messages: Message[]): CoTStep[] { const steps: CoTStep[] = []; const { browserViews, toolCallResults, resultMessages } = indexToolCallData(messages); for (const [messageIndex, message] of messages.entries()) { if (message.type === "ai") { // Reasoning precedes the answer text it produced, so it is pushed first: // step order is what the group renders in, and a message carrying both // would otherwise paint its answer above its own thinking (#4576). const reasoning = extractReasoningContentFromMessage(message); if (reasoning) { const step: CoTReasoningStep = { id: message.id, messageId: message.id, type: "reasoning", reasoning, }; steps.push(step); } const content = extractContentFromMessage(message); if (content) { steps.push({ id: `${message.id ?? `ai-${messageIndex}`}-content`, messageId: message.id, type: "assistantText", content, isClarificationContext: message.tool_calls?.some( (toolCall) => toolCall.name === "ask_clarification", ), }); } for (const tool_call of message.tool_calls ?? []) { if (tool_call.name === "task") { continue; } const step: CoTToolCallStep = { id: tool_call.id, messageId: message.id, type: "toolCall", name: tool_call.name, args: tool_call.args, }; const toolCallId = tool_call.id; if (toolCallId) { const toolCallResult = toolCallResults.get(toolCallId); step.resultMessage = resultMessages.get(toolCallId); // Generic details preserve received text; specialized tools retain their parsing. if (toolCallResult && getToolCallKind(tool_call.name) !== "generic") { try { const json = JSON.parse(toolCallResult); step.result = json; } catch { step.result = toolCallResult; } } step.browserView = browserViews.get(toolCallId); } steps.push(step); } } } return steps; }