import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useSize } from "react-use" import { useTranslation, Trans } from "react-i18next" import deepEqual from "fast-deep-equal" import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react" import type { ClineMessage, FollowUpData, SuggestionItem, ClineApiReqInfo, ClineAskUseMcpServer, ClineSayTool, } from "@roo-code/types" import { Mode } from "@roo/modes" import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences" import { safeJsonParse } from "@roo/core" import { useExtensionState } from "@src/context/ExtensionStateContext" import { findMatchingResourceOrTemplate } from "@src/utils/mcp" import { vscode } from "@src/utils/vscode" import { formatPathTooltip } from "@src/utils/formatPathTooltip" import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock" import { TodoChangeDisplay } from "./TodoChangeDisplay" import CodeAccordian from "../common/CodeAccordian" import MarkdownBlock from "../common/MarkdownBlock" import { ReasoningBlock } from "./ReasoningBlock" import Thumbnails from "../common/Thumbnails" import ImageBlock from "../common/ImageBlock" import ErrorRow from "./ErrorRow" import McpResourceRow from "../mcp/McpResourceRow" import { Mention } from "./Mention" import { CheckpointSaved } from "./checkpoints/CheckpointSaved" import { FollowUpSuggest } from "./FollowUpSuggest" import { BatchFilePermission } from "./BatchFilePermission" import { BatchDiffApproval } from "./BatchDiffApproval" import { ProgressIndicator } from "./ProgressIndicator" import { Markdown } from "./Markdown" import { CommandExecution } from "./CommandExecution" import { CommandExecutionError } from "./CommandExecutionError" import { AutoApprovedRequestLimitWarning } from "./AutoApprovedRequestLimitWarning" import { InProgressRow, CondensationResultRow, CondensationErrorRow, TruncationResultRow } from "./context-management" import CodebaseSearchResultsDisplay from "./CodebaseSearchResultsDisplay" import { appendImages } from "@src/utils/imageUtils" import { McpExecution } from "./McpExecution" import { ChatTextArea } from "./ChatTextArea" import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" import { useSelectedModel } from "../ui/hooks/useSelectedModel" import { Eye, FileDiff, ListTree, User, Edit, Trash2, MessageCircleQuestionMark, SquareArrowOutUpRight, FileCode2, PocketKnife, FolderTree, TerminalSquare, MessageCircle, Repeat2, } from "lucide-react" import { cn } from "@/lib/utils" import { PathTooltip } from "../ui/PathTooltip" // Helper function to get previous todos before a specific message function getPreviousTodos(messages: ClineMessage[], currentMessageTs: number): any[] { // Find the previous updateTodoList message before the current one const previousUpdateIndex = messages .slice() .reverse() .findIndex((msg) => { if (msg.ts >= currentMessageTs) return false if (msg.type === "ask" && msg.ask === "tool") { try { const tool = JSON.parse(msg.text || "{}") return tool.tool === "updateTodoList" } catch { return false } } return false }) if (previousUpdateIndex !== -1) { const previousMessage = messages.slice().reverse()[previousUpdateIndex] try { const tool = JSON.parse(previousMessage.text || "{}") return tool.todos || [] } catch { return [] } } // If no previous updateTodoList message, return empty array return [] } interface ChatRowProps { message: ClineMessage lastModifiedMessage?: ClineMessage isExpanded: boolean isLast: boolean isStreaming: boolean onToggleExpand: (ts: number) => void onHeightChange: (isTaller: boolean) => void onSuggestionClick?: (suggestion: SuggestionItem, event?: React.MouseEvent) => void onBatchFileResponse?: (response: { [key: string]: boolean }) => void onFollowUpUnmount?: () => void isFollowUpAnswered?: boolean isFollowUpAutoApprovalPaused?: boolean editable?: boolean hasCheckpoint?: boolean } // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface ChatRowContentProps extends Omit {} const ChatRow = memo( (props: ChatRowProps) => { const { isLast, onHeightChange, message } = props // Store the previous height to compare with the current height // This allows us to detect changes without causing re-renders const prevHeightRef = useRef(0) const [chatrow, { height }] = useSize(
, ) useEffect(() => { // used for partials, command output, etc. // NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that // height starts off at Infinity if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) { if (!isInitialRender) { onHeightChange(height > prevHeightRef.current) } prevHeightRef.current = height } }, [height, isLast, onHeightChange, message]) // we cannot return null as virtuoso does not support it, so we use a separate visibleMessages array to filter out messages that should not be rendered return chatrow }, // memo does shallow comparison of props, so we need to do deep comparison of arrays/objects whose properties might change deepEqual, ) export default ChatRow export const ChatRowContent = ({ message, lastModifiedMessage, isExpanded, isLast, isStreaming, onToggleExpand, onSuggestionClick, onFollowUpUnmount, onBatchFileResponse, isFollowUpAnswered, isFollowUpAutoApprovalPaused, }: ChatRowContentProps) => { const { t, i18n } = useTranslation() const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode, apiConfiguration, clineMessages } = useExtensionState() const { info: model } = useSelectedModel(apiConfiguration) const [isEditing, setIsEditing] = useState(false) const [editedContent, setEditedContent] = useState("") const [editMode, setEditMode] = useState(mode || "code") const [editImages, setEditImages] = useState([]) // Handle message events for image selection during edit mode useEffect(() => { const handleMessage = (event: MessageEvent) => { const msg = event.data if (msg.type === "selectedImages" && msg.context === "edit" && msg.messageTs === message.ts && isEditing) { setEditImages((prevImages) => appendImages(prevImages, msg.images, MAX_IMAGES_PER_MESSAGE)) } } window.addEventListener("message", handleMessage) return () => window.removeEventListener("message", handleMessage) }, [isEditing, message.ts]) // Memoized callback to prevent re-renders caused by inline arrow functions. const handleToggleExpand = useCallback(() => { onToggleExpand(message.ts) }, [onToggleExpand, message.ts]) // Handle edit button click const handleEditClick = useCallback(() => { setIsEditing(true) setEditedContent(message.text || "") setEditImages(message.images || []) setEditMode(mode || "code") // Edit mode is now handled entirely in the frontend // No need to notify the backend }, [message.text, message.images, mode]) // Handle cancel edit const handleCancelEdit = useCallback(() => { setIsEditing(false) setEditedContent(message.text || "") setEditImages(message.images || []) setEditMode(mode || "code") }, [message.text, message.images, mode]) // Handle save edit const handleSaveEdit = useCallback(() => { setIsEditing(false) // Send edited message to backend vscode.postMessage({ type: "submitEditedMessage", value: message.ts, editedMessageContent: editedContent, images: editImages, }) }, [message.ts, editedContent, editImages]) // Handle image selection for editing const handleSelectImages = useCallback(() => { vscode.postMessage({ type: "selectImages", context: "edit", messageTs: message.ts }) }, [message.ts]) const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => { if (message.text !== null && message.text !== undefined && message.say === "api_req_started") { const info = safeJsonParse(message.text) return [info?.cost, info?.cancelReason, info?.streamingFailedMessage] } return [undefined, undefined, undefined] }, [message.text, message.say]) // When resuming task, last wont be api_req_failed but a resume_task // message, so api_req_started will show loading spinner. That's why we just // remove the last api_req_started that failed without streaming anything. const apiRequestFailedMessage = isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried ? lastModifiedMessage?.text : undefined const isCommandExecuting = isLast && lastModifiedMessage?.ask === "command" && lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING) const isMcpServerResponding = isLast && lastModifiedMessage?.say === "mcp_server_request_started" const type = message.type === "ask" ? message.ask : message.say const normalColor = "var(--vscode-foreground)" const errorColor = "var(--vscode-errorForeground)" const successColor = "var(--vscode-charts-green)" const cancelledColor = "var(--vscode-descriptionForeground)" const [icon, title] = useMemo(() => { switch (type) { case "error": case "mistake_limit_reached": return [null, null] // These will be handled by ErrorRow component case "command": return [ isCommandExecuting ? ( ) : ( ), {t("chat:commandExecution.running")} , ] case "use_mcp_server": const mcpServerUse = safeJsonParse(message.text) if (mcpServerUse === undefined) { return [null, null] } return [ isMcpServerResponding ? ( ) : ( ), {mcpServerUse.type === "use_mcp_tool" ? t("chat:mcp.wantsToUseTool", { serverName: mcpServerUse.serverName }) : t("chat:mcp.wantsToAccessResource", { serverName: mcpServerUse.serverName })} , ] case "completion_result": return [ , {t("chat:taskCompleted")}, ] case "api_req_rate_limit_wait": return [] case "api_req_retry_delayed": return [] case "api_req_started": const getIconSpan = (iconName: string, color: string) => (
) return [ apiReqCancelReason !== null && apiReqCancelReason !== undefined ? ( apiReqCancelReason === "user_cancelled" ? ( getIconSpan("error", cancelledColor) ) : ( getIconSpan("error", errorColor) ) ) : cost !== null && cost !== undefined ? ( getIconSpan("arrow-swap", normalColor) ) : apiRequestFailedMessage ? ( getIconSpan("error", errorColor) ) : isLast ? ( ) : ( getIconSpan("arrow-swap", normalColor) ), apiReqCancelReason !== null && apiReqCancelReason !== undefined ? ( apiReqCancelReason === "user_cancelled" ? ( {t("chat:apiRequest.cancelled")} ) : ( {t("chat:apiRequest.streamingFailed")} ) ) : cost !== null && cost !== undefined ? ( {t("chat:apiRequest.title")} ) : apiRequestFailedMessage ? ( {t("chat:apiRequest.failed")} ) : ( {t("chat:apiRequest.streaming")} ), ] case "followup": return [ , {t("chat:questions.hasQuestion")}, ] default: return [null, null] } }, [ type, isCommandExecuting, message, isMcpServerResponding, apiReqCancelReason, cost, apiRequestFailedMessage, t, isLast, ]) const headerStyle: React.CSSProperties = { display: "flex", alignItems: "center", gap: "10px", marginBottom: "10px", wordBreak: "break-word", } const tool = useMemo( () => (message.ask === "tool" ? safeJsonParse(message.text) : null), [message.ask, message.text], ) // Unified diff content (provided by backend when relevant) const unifiedDiff = useMemo(() => { if (!tool) return undefined return (tool.content ?? tool.diff) as string | undefined }, [tool]) const followUpData = useMemo(() => { if (message.type === "ask" && message.ask === "followup" && !message.partial) { return safeJsonParse(message.text) } return null }, [message.type, message.ask, message.partial, message.text]) if (tool) { const toolIcon = (name: string) => ( ) switch (tool.tool as string) { case "editedExistingFile": case "appliedDiff": // Check if this is a batch diff request if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) { return ( <>
{t("chat:fileOperations.wantsToApplyBatchChanges")}
) } // Regular single file diff return ( <>
{tool.isProtected ? ( ) : ( toolIcon(tool.tool === "appliedDiff" ? "diff" : "edit") )} {tool.isProtected ? t("chat:fileOperations.wantsToEditProtected") : tool.isOutsideWorkspace ? t("chat:fileOperations.wantsToEditOutsideWorkspace") : t("chat:fileOperations.wantsToEdit")}
) case "insertContent": return ( <>
{tool.isProtected ? ( ) : ( toolIcon("insert") )} {tool.isProtected ? t("chat:fileOperations.wantsToEditProtected") : tool.isOutsideWorkspace ? t("chat:fileOperations.wantsToEditOutsideWorkspace") : tool.lineNumber === 0 ? t("chat:fileOperations.wantsToInsertAtEnd") : t("chat:fileOperations.wantsToInsertWithLineNumber", { lineNumber: tool.lineNumber, })}
) case "searchAndReplace": return ( <>
{tool.isProtected ? ( ) : ( toolIcon("replace") )} {tool.isProtected && message.type === "ask" ? t("chat:fileOperations.wantsToEditProtected") : message.type === "ask" ? t("chat:fileOperations.wantsToSearchReplace") : t("chat:fileOperations.didSearchReplace")}
) case "codebaseSearch": { return (
{toolIcon("search")} {tool.path ? ( }} values={{ query: tool.query, path: tool.path }} /> ) : ( }} values={{ query: tool.query }} /> )}
) } case "updateTodoList" as any: { const todos = (tool as any).todos || [] // Get previous todos from the latest todos in the task context const previousTodos = getPreviousTodos(clineMessages, message.ts) return } case "newFileCreated": return ( <>
{tool.isProtected ? ( ) : ( toolIcon("new-file") )} {tool.isProtected ? t("chat:fileOperations.wantsToEditProtected") : t("chat:fileOperations.wantsToCreate")}
vscode.postMessage({ type: "openFile", text: "./" + tool.path })} diffStats={tool.diffStats} />
) case "readFile": // Check if this is a batch file permission request const isBatchRequest = message.type === "ask" && tool.batchFiles && Array.isArray(tool.batchFiles) if (isBatchRequest) { return ( <>
{t("chat:fileOperations.wantsToReadMultiple")}
{ onBatchFileResponse?.(response) }} ts={message?.ts} /> ) } // Regular single file read request return ( <>
{message.type === "ask" ? tool.isOutsideWorkspace ? t("chat:fileOperations.wantsToReadOutsideWorkspace") : tool.additionalFileCount && tool.additionalFileCount > 0 ? t("chat:fileOperations.wantsToReadAndXMore", { count: tool.additionalFileCount, }) : t("chat:fileOperations.wantsToRead") : t("chat:fileOperations.didRead")}
vscode.postMessage({ type: "openFile", text: tool.content })}> {tool.path?.startsWith(".") && .} {formatPathTooltip(tool.path, tool.reason)}
) case "fetchInstructions": return ( <>
{toolIcon("file-code")} {t("chat:instructions.wantsToFetch")}
) case "listFilesTopLevel": return ( <>
{message.type === "ask" ? tool.isOutsideWorkspace ? t("chat:directoryOperations.wantsToViewTopLevelOutsideWorkspace") : t("chat:directoryOperations.wantsToViewTopLevel") : tool.isOutsideWorkspace ? t("chat:directoryOperations.didViewTopLevelOutsideWorkspace") : t("chat:directoryOperations.didViewTopLevel")}
) case "listFilesRecursive": return ( <>
{message.type === "ask" ? tool.isOutsideWorkspace ? t("chat:directoryOperations.wantsToViewRecursiveOutsideWorkspace") : t("chat:directoryOperations.wantsToViewRecursive") : tool.isOutsideWorkspace ? t("chat:directoryOperations.didViewRecursiveOutsideWorkspace") : t("chat:directoryOperations.didViewRecursive")}
) case "searchFiles": return ( <>
{toolIcon("search")} {message.type === "ask" ? ( {tool.regex} }} values={{ regex: tool.regex }} /> ) : ( {tool.regex} }} values={{ regex: tool.regex }} /> )}
) case "switchMode": return ( <>
{message.type === "ask" ? ( <> {tool.reason ? ( {tool.mode} }} values={{ mode: tool.mode, reason: tool.reason }} /> ) : ( {tool.mode} }} values={{ mode: tool.mode }} /> )} ) : ( <> {tool.reason ? ( {tool.mode} }} values={{ mode: tool.mode, reason: tool.reason }} /> ) : ( {tool.mode} }} values={{ mode: tool.mode }} /> )} )}
) case "newTask": return ( <>
{toolIcon("tasklist")} {tool.mode} }} values={{ mode: tool.mode }} />
{t("chat:subtasks.newTaskContent")}
) case "finishTask": return ( <>
{toolIcon("check-all")} {t("chat:subtasks.wantsToFinish")}
{t("chat:subtasks.completionContent")}
) case "runSlashCommand": { const slashCommandInfo = tool return ( <>
{toolIcon("play")} {message.type === "ask" ? t("chat:slashCommand.wantsToRun") : t("chat:slashCommand.didRun")}
/{slashCommandInfo.command} {slashCommandInfo.source && ( {slashCommandInfo.source} )}
{isExpanded && (slashCommandInfo.args || slashCommandInfo.description) && (
{slashCommandInfo.args && (
Arguments: {slashCommandInfo.args}
)} {slashCommandInfo.description && (
{slashCommandInfo.description}
)}
)}
) } case "generateImage": return ( <>
{tool.isProtected ? ( ) : ( toolIcon("file-media") )} {message.type === "ask" ? tool.isProtected ? t("chat:fileOperations.wantsToGenerateImageProtected") : tool.isOutsideWorkspace ? t("chat:fileOperations.wantsToGenerateImageOutsideWorkspace") : t("chat:fileOperations.wantsToGenerateImage") : t("chat:fileOperations.didGenerateImage")}
{message.type === "ask" && (
{tool.content}
{tool.path}
)} ) default: return null } } switch (message.type) { case "say": switch (message.say) { case "diff_error": return ( ) case "subtask_result": return (
{t("chat:subtasks.resultContent")}
) case "reasoning": return ( ) case "api_req_started": // Determine if the API request is in progress const isApiRequestInProgress = apiReqCancelReason === undefined && apiRequestFailedMessage === undefined && cost === undefined return ( <>
{icon} {title}
0 ? 1 : 0 }}> ${Number(cost || 0)?.toFixed(4)}
{(((cost === null || cost === undefined) && apiRequestFailedMessage) || apiReqStreamingFailedMessage) && ( )} ) case "api_req_retry_delayed": let body = t(`chat:apiRequest.failed`) let retryInfo, rawError, code, docsURL if (message.text !== undefined) { // Check for Claude Code authentication error first if (message.text.includes("Not authenticated with Claude Code")) { body = t("chat:apiRequest.errorMessage.claudeCodeNotAuthenticated") docsURL = "roocode://settings?provider=claude-code" } else { // Try to show richer error message for that code, if available const potentialCode = parseInt(message.text.substring(0, 3)) if (!isNaN(potentialCode) && potentialCode >= 400) { code = potentialCode const stringForError = `chat:apiRequest.errorMessage.${code}` if (i18n.exists(stringForError)) { body = t(stringForError) // Fill this out in upcoming PRs // Do not remove this // switch(code) { // case ERROR_CODE: // docsURL = ??? // break; // } } else { body = t("chat:apiRequest.errorMessage.unknown") docsURL = "mailto:support@roocode.com?subject=Unknown API Error&body=[Please include full error details]" } } else if (message.text.indexOf("Connection error") === 0) { body = t("chat:apiRequest.errorMessage.connection") } else { // Non-HTTP-status-code error message - store full text as errorDetails body = t("chat:apiRequest.errorMessage.unknown") docsURL = "mailto:support@roocode.com?subject=Unknown API Error&body=[Please include full error details]" } } // This isn't pretty, but since the retry logic happens at a lower level // and the message object is just a flat string, we need to extract the // retry information using this "tag" as a convention const retryTimerMatch = message.text.match(/(.*?)<\/retry_timer>/) const retryTimer = retryTimerMatch && retryTimerMatch[1] ? parseInt(retryTimerMatch[1], 10) : 0 rawError = message.text.replace(/(.*?)<\/retry_timer>/, "").trim() retryInfo = retryTimer > 0 && (

{retryTimer}s

) } return ( ) case "api_req_rate_limit_wait": { const isWaiting = message.partial === true const waitSeconds = (() => { if (!message.text) return undefined try { const data = JSON.parse(message.text) return typeof data.seconds === "number" ? data.seconds : undefined } catch { return undefined } })() return isWaiting && waitSeconds !== undefined ? (
{t("chat:apiRequest.rateLimitWait")}
{waitSeconds}s
) : null } case "api_req_finished": return null // we should never see this message type case "text": return (
{t("chat:text.rooSaid")}
{message.images && message.images.length > 0 && (
{message.images.map((image, index) => ( ))}
)}
) case "user_feedback": return (
{t("chat:feedback.youSaid")}
{isEditing ? (
) : (
{ e.stopPropagation() if (!isStreaming) { handleEditClick() } }} title={t("chat:queuedMessages.clickToEdit")}>
{ e.stopPropagation() handleEditClick() }}>
{ e.stopPropagation() vscode.postMessage({ type: "deleteMessage", value: message.ts }) }}>
)} {!isEditing && message.images && message.images.length > 0 && ( )}
) case "user_feedback_diff": const tool = safeJsonParse(message.text) return (
) case "error": // Check if this is a model response error based on marker strings from backend const isNoToolsUsedError = message.text === "MODEL_NO_TOOLS_USED" const isNoAssistantMessagesError = message.text === "MODEL_NO_ASSISTANT_MESSAGES" if (isNoToolsUsedError) { return ( ) } if (isNoAssistantMessagesError) { return ( ) } // Fallback for generic errors return ( ) case "completion_result": return ( <>
{icon} {title}
) case "shell_integration_warning": return case "checkpoint_saved": return ( ) case "condense_context": // In-progress state if (message.partial) { return } // Completed state if (message.contextCondense) { return } return null case "condense_context_error": return case "sliding_window_truncation": // In-progress state if (message.partial) { return } // Completed state if (message.contextTruncation) { return } return null case "codebase_search_result": let parsed: { content: { query: string results: Array<{ filePath: string score: number startLine: number endLine: number codeChunk: string }> } } | null = null try { if (message.text) { parsed = JSON.parse(message.text) } } catch (error) { console.error("Failed to parse codebaseSearch content:", error) } if (parsed && !parsed?.content) { console.error("Invalid codebaseSearch content structure:", parsed.content) return
Error displaying search results.
} const { results = [] } = parsed?.content || {} return case "user_edit_todos": return {}} /> case "tool" as any: // Handle say tool messages const sayTool = safeJsonParse(message.text) if (!sayTool) return null switch (sayTool.tool) { case "runSlashCommand": { const slashCommandInfo = sayTool return ( <>
{t("chat:slashCommand.didRun")}
/{slashCommandInfo.command} {slashCommandInfo.args && ( {slashCommandInfo.args} )}
{slashCommandInfo.description && (
{slashCommandInfo.description}
)} {slashCommandInfo.source && (
{slashCommandInfo.source}
)}
) } default: return null } case "image": // Parse the JSON to get imageUri and imagePath const imageInfo = safeJsonParse<{ imageUri: string; imagePath: string }>(message.text || "{}") if (!imageInfo) { return null } return (
) case "browser_action": case "browser_action_result": // Handled by BrowserSessionRow; prevent raw JSON (action/result) from rendering here return null default: return ( <> {title && (
{icon} {title}
)}
) } case "ask": switch (message.ask) { case "mistake_limit_reached": return case "command": return ( ) case "use_mcp_server": // Parse the message text to get the MCP server request const messageJson = safeJsonParse(message.text, {}) // Extract the response field if it exists const { response, ...mcpServerRequest } = messageJson // Create the useMcpServer object with the response field const useMcpServer: ClineAskUseMcpServer = { ...mcpServerRequest, response, } if (!useMcpServer) { return null } const server = mcpServers.find((server) => server.name === useMcpServer.serverName) return ( <>
{icon} {title}
{useMcpServer.type === "access_mcp_resource" && ( )} {useMcpServer.type === "use_mcp_tool" && ( )}
) case "completion_result": if (message.text) { return (
{icon} {title}
) } else { return null // Don't render anything when we get a completion_result ask without text } case "followup": return ( <> {title && (
{icon} {title}
)}
) case "auto_approval_max_req_reached": { return } default: return null } } }