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, VSCodeButton } from "@vscode/webview-ui-toolkit/react" import type { ClineMessage, FollowUpData, SuggestionItem } from "@roo-code/types" import { ClineApiReqInfo, ClineAskUseMcpServer, ClineSayTool } from "@roo/ExtensionMessage" import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences" import { safeJsonParse } from "@roo/safeJsonParse" import { useCopyToClipboard } from "@src/utils/clipboard" import { useExtensionState } from "@src/context/ExtensionStateContext" import { findMatchingResourceOrTemplate } from "@src/utils/mcp" import { vscode } from "@src/utils/vscode" import { removeLeadingNonAlphanumeric } from "@src/utils/removeLeadingNonAlphanumeric" import { getLanguageFromPath } from "@src/utils/getLanguageFromPath" import { Button } from "@src/components/ui" import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock" import CodeAccordian from "../common/CodeAccordian" import CodeBlock from "../common/CodeBlock" import MarkdownBlock from "../common/MarkdownBlock" import { ReasoningBlock } from "./ReasoningBlock" import Thumbnails from "../common/Thumbnails" import ImageBlock from "../common/ImageBlock" 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 { CondenseContextErrorRow, CondensingContextRow, ContextCondenseRow } from "./ContextCondenseRow" import CodebaseSearchResultsDisplay from "./CodebaseSearchResultsDisplay" import { McpExecution } from "./McpExecution" 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 editable?: 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, editable, }: ChatRowContentProps) => { const { t } = useTranslation() const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState() const [reasoningCollapsed, setReasoningCollapsed] = useState(true) const [isDiffErrorExpanded, setIsDiffErrorExpanded] = useState(false) const [showCopySuccess, setShowCopySuccess] = useState(false) const { copyWithFeedback } = useCopyToClipboard() // Memoized callback to prevent re-renders caused by inline arrow functions. const handleToggleExpand = useCallback(() => { onToggleExpand(message.ts) }, [onToggleExpand, 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": return [ , {t("chat:error")}, ] case "mistake_limit_reached": return [ , {t("chat:troubleMessage")}, ] case "command": return [ isCommandExecuting ? ( ) : ( ), {t("chat:runCommand.title")}:, ] 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_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("check", successColor) ) : apiRequestFailedMessage ? ( getIconSpan("error", errorColor) ) : ( ), 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]) const headerStyle: React.CSSProperties = { display: "flex", alignItems: "center", gap: "10px", marginBottom: "10px", wordBreak: "break-word", } const pStyle: React.CSSProperties = { margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word", overflowWrap: "anywhere", } const tool = useMemo( () => (message.ask === "tool" ? safeJsonParse(message.text) : null), [message.ask, message.text], ) 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) { case "editedExistingFile": case "appliedDiff": // Check if this is a batch diff request if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) { return ( <>
{toolIcon("diff")} {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 || [] return ( { if (typeof vscode !== "undefined" && vscode?.postMessage) { vscode.postMessage({ type: "updateTodoList", payload: { todos: updatedTodos } }) } }} editable={editable && isLast} /> ) } 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 })} /> ) 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 ( <>
{toolIcon("files")} {t("chat:fileOperations.wantsToReadMultiple")}
{ onBatchFileResponse?.(response) }} ts={message?.ts} /> ) } // Regular single file read request return ( <>
{toolIcon("file-code")} {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(".") && .} {removeLeadingNonAlphanumeric(tool.path ?? "") + "\u200E"} {tool.reason}
) case "fetchInstructions": return ( <>
{toolIcon("file-code")} {t("chat:instructions.wantsToFetch")}
) case "listFilesTopLevel": return ( <>
{toolIcon("folder-opened")} {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 ( <>
{toolIcon("folder-opened")} {message.type === "ask" ? tool.isOutsideWorkspace ? t("chat:directoryOperations.wantsToViewRecursiveOutsideWorkspace") : t("chat:directoryOperations.wantsToViewRecursive") : tool.isOutsideWorkspace ? t("chat:directoryOperations.didViewRecursiveOutsideWorkspace") : t("chat:directoryOperations.didViewRecursive")}
) case "listCodeDefinitionNames": return ( <>
{toolIcon("file-code")} {message.type === "ask" ? tool.isOutsideWorkspace ? t("chat:directoryOperations.wantsToViewDefinitionsOutsideWorkspace") : t("chat:directoryOperations.wantsToViewDefinitions") : tool.isOutsideWorkspace ? t("chat:directoryOperations.didViewDefinitionsOutsideWorkspace") : t("chat:directoryOperations.didViewDefinitions")}
) case "searchFiles": return ( <>
{toolIcon("search")} {message.type === "ask" ? ( {tool.regex} }} values={{ regex: tool.regex }} /> ) : ( {tool.regex} }} values={{ regex: tool.regex }} /> )}
) case "switchMode": return ( <>
{toolIcon("symbol-enum")} {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 "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" && ( )} ) default: return null } } switch (message.type) { case "say": switch (message.say) { case "diff_error": return (
setIsDiffErrorExpanded(!isDiffErrorExpanded)}>
{t("chat:diffError.title")}
{ e.stopPropagation() // Call copyWithFeedback and handle the Promise copyWithFeedback(message.text || "").then((success) => { if (success) { // Show checkmark setShowCopySuccess(true) // Reset after a brief delay setTimeout(() => { setShowCopySuccess(false) }, 1000) } }) }}>
{isDiffErrorExpanded && (
)}
) case "subtask_result": return (
{t("chat:subtasks.resultContent")}
) case "reasoning": return ( setReasoningCollapsed(!reasoningCollapsed)} /> ) case "api_req_started": return ( <>
{icon} {title} 0 ? 1 : 0 }}> ${Number(cost || 0)?.toFixed(4)}
{(((cost === null || cost === undefined) && apiRequestFailedMessage) || apiReqStreamingFailedMessage) && ( <>

{apiRequestFailedMessage || apiReqStreamingFailedMessage} {apiRequestFailedMessage?.toLowerCase().includes("powershell") && ( <>

{t("chat:powershell.issues")}{" "} troubleshooting guide . )}

)} {isExpanded && (
(message.text)?.request} language="markdown" isExpanded={true} onToggleExpand={handleToggleExpand} />
)} ) case "api_req_finished": return null // we should never see this message type case "text": return (
{message.images && message.images.length > 0 && (
{message.images.map((image, index) => ( ))}
)}
) case "user_feedback": return (
{message.images && message.images.length > 0 && ( )}
) case "user_feedback_diff": const tool = safeJsonParse(message.text) return (
) case "error": return ( <> {title && (
{icon} {title}
)}

{message.text}

) case "completion_result": return ( <>
{icon} {title}
) case "shell_integration_warning": return case "checkpoint_saved": return ( ) case "condense_context": if (message.partial) { return } return message.contextCondense ? : null case "condense_context_error": return 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 "image": // Parse the JSON to get imageUri and imagePath const imageInfo = safeJsonParse<{ imageUri: string; imagePath: string }>(message.text || "{}") if (!imageInfo) { return null } return (
) default: return ( <> {title && (
{icon} {title}
)}
) } case "ask": switch (message.ask) { case "mistake_limit_reached": return ( <>
{icon} {title}

{message.text}

) 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 } } }