Roo-Code/webview-ui/src/components/chat/ChatRow.tsx
Daniel fad219e001
feat: optimize memory usage for image handling in webview (#7556)
* feat: optimize memory usage for image handling in webview

- Replace base64 image data with webview URIs to reduce memory footprint
- Add proper resource roots to webview for workspace file access
- Implement convertToWebviewUri method for safe file-to-URI conversion
- Update ImageViewer to handle both webview URIs and file paths separately
- Add image message type for proper image rendering in chat
- Improve error handling and display for failed image loads
- Add comprehensive tests for ImageViewer component
- Format display paths as relative for better readability

This change significantly reduces memory usage by avoiding base64 encoding
of images and instead using VSCode's webview URI system for direct file
access. Images are now loaded on-demand from disk rather than being held
in memory as base64 strings.

* fix: address PR review comments

- Use safeJsonParse instead of JSON.parse in ChatRow.tsx
- Add type definition for parsed image info
- Add more specific error types in ClineProvider.ts
- Add comprehensive JSDoc comments to ImageBlock.tsx
- Improve error handling and type safety

* fix: address MrUbens' review comments

- Remove hardcoded 'rc1' pattern in formatDisplayPath, use generic workspace detection
- Internationalize 'No image data' text using i18n system

* chore: remove useless comment

* chore(i18n): add image.noData to all locales to fix translation check

* test: update ImageViewer.spec to align with i18n key and flexible path formatting
2025-08-30 15:57:30 -04:00

1314 lines
40 KiB
TypeScript

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<ChatRowProps, "onHeightChange"> {}
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(
<div className="px-[15px] py-[10px] pr-[6px]">
<ChatRowContent {...props} />
</div>,
)
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<ClineApiReqInfo>(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 [
<span
className="codicon codicon-error"
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>,
<span style={{ color: errorColor, fontWeight: "bold" }}>{t("chat:error")}</span>,
]
case "mistake_limit_reached":
return [
<span
className="codicon codicon-error"
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>,
<span style={{ color: errorColor, fontWeight: "bold" }}>{t("chat:troubleMessage")}</span>,
]
case "command":
return [
isCommandExecuting ? (
<ProgressIndicator />
) : (
<span
className="codicon codicon-terminal"
style={{ color: normalColor, marginBottom: "-1.5px" }}></span>
),
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:runCommand.title")}:</span>,
]
case "use_mcp_server":
const mcpServerUse = safeJsonParse<ClineAskUseMcpServer>(message.text)
if (mcpServerUse === undefined) {
return [null, null]
}
return [
isMcpServerResponding ? (
<ProgressIndicator />
) : (
<span
className="codicon codicon-server"
style={{ color: normalColor, marginBottom: "-1.5px" }}></span>
),
<span style={{ color: normalColor, fontWeight: "bold" }}>
{mcpServerUse.type === "use_mcp_tool"
? t("chat:mcp.wantsToUseTool", { serverName: mcpServerUse.serverName })
: t("chat:mcp.wantsToAccessResource", { serverName: mcpServerUse.serverName })}
</span>,
]
case "completion_result":
return [
<span
className="codicon codicon-check"
style={{ color: successColor, marginBottom: "-1.5px" }}></span>,
<span style={{ color: successColor, fontWeight: "bold" }}>{t("chat:taskCompleted")}</span>,
]
case "api_req_retry_delayed":
return []
case "api_req_started":
const getIconSpan = (iconName: string, color: string) => (
<div
style={{
width: 16,
height: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<span
className={`codicon codicon-${iconName}`}
style={{ color, fontSize: 16, marginBottom: "-1.5px" }}
/>
</div>
)
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)
) : (
<ProgressIndicator />
),
apiReqCancelReason !== null && apiReqCancelReason !== undefined ? (
apiReqCancelReason === "user_cancelled" ? (
<span style={{ color: normalColor, fontWeight: "bold" }}>
{t("chat:apiRequest.cancelled")}
</span>
) : (
<span style={{ color: errorColor, fontWeight: "bold" }}>
{t("chat:apiRequest.streamingFailed")}
</span>
)
) : cost !== null && cost !== undefined ? (
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:apiRequest.title")}</span>
) : apiRequestFailedMessage ? (
<span style={{ color: errorColor, fontWeight: "bold" }}>{t("chat:apiRequest.failed")}</span>
) : (
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:apiRequest.streaming")}</span>
),
]
case "followup":
return [
<span
className="codicon codicon-question"
style={{ color: normalColor, marginBottom: "-1.5px" }}
/>,
<span style={{ color: normalColor, fontWeight: "bold" }}>{t("chat:questions.hasQuestion")}</span>,
]
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<ClineSayTool>(message.text) : null),
[message.ask, message.text],
)
const followUpData = useMemo(() => {
if (message.type === "ask" && message.ask === "followup" && !message.partial) {
return safeJsonParse<FollowUpData>(message.text)
}
return null
}, [message.type, message.ask, message.partial, message.text])
if (tool) {
const toolIcon = (name: string) => (
<span
className={`codicon codicon-${name}`}
style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
)
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 (
<>
<div style={headerStyle}>
{toolIcon("diff")}
<span style={{ fontWeight: "bold" }}>
{t("chat:fileOperations.wantsToApplyBatchChanges")}
</span>
</div>
<BatchDiffApproval files={tool.batchDiffs} ts={message.ts} />
</>
)
}
// Regular single file diff
return (
<>
<div style={headerStyle}>
{tool.isProtected ? (
<span
className="codicon codicon-lock"
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
/>
) : (
toolIcon(tool.tool === "appliedDiff" ? "diff" : "edit")
)}
<span style={{ fontWeight: "bold" }}>
{tool.isProtected
? t("chat:fileOperations.wantsToEditProtected")
: tool.isOutsideWorkspace
? t("chat:fileOperations.wantsToEditOutsideWorkspace")
: t("chat:fileOperations.wantsToEdit")}
</span>
</div>
<CodeAccordian
path={tool.path}
code={tool.content ?? tool.diff}
language="diff"
progressStatus={message.progressStatus}
isLoading={message.partial}
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
/>
</>
)
case "insertContent":
return (
<>
<div style={headerStyle}>
{tool.isProtected ? (
<span
className="codicon codicon-lock"
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
/>
) : (
toolIcon("insert")
)}
<span style={{ fontWeight: "bold" }}>
{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,
})}
</span>
</div>
<CodeAccordian
path={tool.path}
code={tool.diff}
language="diff"
progressStatus={message.progressStatus}
isLoading={message.partial}
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
/>
</>
)
case "searchAndReplace":
return (
<>
<div style={headerStyle}>
{tool.isProtected ? (
<span
className="codicon codicon-lock"
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
/>
) : (
toolIcon("replace")
)}
<span style={{ fontWeight: "bold" }}>
{tool.isProtected && message.type === "ask"
? t("chat:fileOperations.wantsToEditProtected")
: message.type === "ask"
? t("chat:fileOperations.wantsToSearchReplace")
: t("chat:fileOperations.didSearchReplace")}
</span>
</div>
<CodeAccordian
path={tool.path}
code={tool.diff}
language="diff"
progressStatus={message.progressStatus}
isLoading={message.partial}
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
/>
</>
)
case "codebaseSearch": {
return (
<div style={headerStyle}>
{toolIcon("search")}
<span style={{ fontWeight: "bold" }}>
{tool.path ? (
<Trans
i18nKey="chat:codebaseSearch.wantsToSearchWithPath"
components={{ code: <code></code> }}
values={{ query: tool.query, path: tool.path }}
/>
) : (
<Trans
i18nKey="chat:codebaseSearch.wantsToSearch"
components={{ code: <code></code> }}
values={{ query: tool.query }}
/>
)}
</span>
</div>
)
}
case "updateTodoList" as any: {
const todos = (tool as any).todos || []
return (
<UpdateTodoListToolBlock
todos={todos}
content={(tool as any).content}
onChange={(updatedTodos) => {
if (typeof vscode !== "undefined" && vscode?.postMessage) {
vscode.postMessage({ type: "updateTodoList", payload: { todos: updatedTodos } })
}
}}
editable={editable && isLast}
/>
)
}
case "newFileCreated":
return (
<>
<div style={headerStyle}>
{tool.isProtected ? (
<span
className="codicon codicon-lock"
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
/>
) : (
toolIcon("new-file")
)}
<span style={{ fontWeight: "bold" }}>
{tool.isProtected
? t("chat:fileOperations.wantsToEditProtected")
: t("chat:fileOperations.wantsToCreate")}
</span>
</div>
<CodeAccordian
path={tool.path}
code={tool.content}
language={getLanguageFromPath(tool.path || "") || "log"}
isLoading={message.partial}
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
onJumpToFile={() => 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 (
<>
<div style={headerStyle}>
{toolIcon("files")}
<span style={{ fontWeight: "bold" }}>
{t("chat:fileOperations.wantsToReadMultiple")}
</span>
</div>
<BatchFilePermission
files={tool.batchFiles || []}
onPermissionResponse={(response) => {
onBatchFileResponse?.(response)
}}
ts={message?.ts}
/>
</>
)
}
// Regular single file read request
return (
<>
<div style={headerStyle}>
{toolIcon("file-code")}
<span style={{ fontWeight: "bold" }}>
{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")}
</span>
</div>
<ToolUseBlock>
<ToolUseBlockHeader
onClick={() => vscode.postMessage({ type: "openFile", text: tool.content })}>
{tool.path?.startsWith(".") && <span>.</span>}
<span className="whitespace-nowrap overflow-hidden text-ellipsis text-left mr-2 rtl">
{removeLeadingNonAlphanumeric(tool.path ?? "") + "\u200E"}
{tool.reason}
</span>
<div style={{ flexGrow: 1 }}></div>
<span
className={`codicon codicon-link-external`}
style={{ fontSize: 13.5, margin: "1px 0" }}
/>
</ToolUseBlockHeader>
</ToolUseBlock>
</>
)
case "fetchInstructions":
return (
<>
<div style={headerStyle}>
{toolIcon("file-code")}
<span style={{ fontWeight: "bold" }}>{t("chat:instructions.wantsToFetch")}</span>
</div>
<CodeAccordian
code={tool.content}
language="markdown"
isLoading={message.partial}
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
/>
</>
)
case "listFilesTopLevel":
return (
<>
<div style={headerStyle}>
{toolIcon("folder-opened")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask"
? tool.isOutsideWorkspace
? t("chat:directoryOperations.wantsToViewTopLevelOutsideWorkspace")
: t("chat:directoryOperations.wantsToViewTopLevel")
: tool.isOutsideWorkspace
? t("chat:directoryOperations.didViewTopLevelOutsideWorkspace")
: t("chat:directoryOperations.didViewTopLevel")}
</span>
</div>
<CodeAccordian
path={tool.path}
code={tool.content}
language="shell-session"
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
/>
</>
)
case "listFilesRecursive":
return (
<>
<div style={headerStyle}>
{toolIcon("folder-opened")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask"
? tool.isOutsideWorkspace
? t("chat:directoryOperations.wantsToViewRecursiveOutsideWorkspace")
: t("chat:directoryOperations.wantsToViewRecursive")
: tool.isOutsideWorkspace
? t("chat:directoryOperations.didViewRecursiveOutsideWorkspace")
: t("chat:directoryOperations.didViewRecursive")}
</span>
</div>
<CodeAccordian
path={tool.path}
code={tool.content}
language="shellsession"
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
/>
</>
)
case "listCodeDefinitionNames":
return (
<>
<div style={headerStyle}>
{toolIcon("file-code")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask"
? tool.isOutsideWorkspace
? t("chat:directoryOperations.wantsToViewDefinitionsOutsideWorkspace")
: t("chat:directoryOperations.wantsToViewDefinitions")
: tool.isOutsideWorkspace
? t("chat:directoryOperations.didViewDefinitionsOutsideWorkspace")
: t("chat:directoryOperations.didViewDefinitions")}
</span>
</div>
<CodeAccordian
path={tool.path}
code={tool.content}
language="markdown"
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
/>
</>
)
case "searchFiles":
return (
<>
<div style={headerStyle}>
{toolIcon("search")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask" ? (
<Trans
i18nKey={
tool.isOutsideWorkspace
? "chat:directoryOperations.wantsToSearchOutsideWorkspace"
: "chat:directoryOperations.wantsToSearch"
}
components={{ code: <code>{tool.regex}</code> }}
values={{ regex: tool.regex }}
/>
) : (
<Trans
i18nKey={
tool.isOutsideWorkspace
? "chat:directoryOperations.didSearchOutsideWorkspace"
: "chat:directoryOperations.didSearch"
}
components={{ code: <code>{tool.regex}</code> }}
values={{ regex: tool.regex }}
/>
)}
</span>
</div>
<CodeAccordian
path={tool.path! + (tool.filePattern ? `/(${tool.filePattern})` : "")}
code={tool.content}
language="shellsession"
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
/>
</>
)
case "switchMode":
return (
<>
<div style={headerStyle}>
{toolIcon("symbol-enum")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask" ? (
<>
{tool.reason ? (
<Trans
i18nKey="chat:modes.wantsToSwitchWithReason"
components={{ code: <code>{tool.mode}</code> }}
values={{ mode: tool.mode, reason: tool.reason }}
/>
) : (
<Trans
i18nKey="chat:modes.wantsToSwitch"
components={{ code: <code>{tool.mode}</code> }}
values={{ mode: tool.mode }}
/>
)}
</>
) : (
<>
{tool.reason ? (
<Trans
i18nKey="chat:modes.didSwitchWithReason"
components={{ code: <code>{tool.mode}</code> }}
values={{ mode: tool.mode, reason: tool.reason }}
/>
) : (
<Trans
i18nKey="chat:modes.didSwitch"
components={{ code: <code>{tool.mode}</code> }}
values={{ mode: tool.mode }}
/>
)}
</>
)}
</span>
</div>
</>
)
case "newTask":
return (
<>
<div style={headerStyle}>
{toolIcon("tasklist")}
<span style={{ fontWeight: "bold" }}>
<Trans
i18nKey="chat:subtasks.wantsToCreate"
components={{ code: <code>{tool.mode}</code> }}
values={{ mode: tool.mode }}
/>
</span>
</div>
<div
style={{
marginTop: "4px",
backgroundColor: "var(--vscode-badge-background)",
border: "1px solid var(--vscode-badge-background)",
borderRadius: "4px 4px 0 0",
overflow: "hidden",
marginBottom: "2px",
}}>
<div
style={{
padding: "9px 10px 9px 14px",
backgroundColor: "var(--vscode-badge-background)",
borderBottom: "1px solid var(--vscode-editorGroup-border)",
fontWeight: "bold",
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-badge-foreground)",
display: "flex",
alignItems: "center",
gap: "6px",
}}>
<span className="codicon codicon-arrow-right"></span>
{t("chat:subtasks.newTaskContent")}
</div>
<div style={{ padding: "12px 16px", backgroundColor: "var(--vscode-editor-background)" }}>
<MarkdownBlock markdown={tool.content} />
</div>
</div>
</>
)
case "finishTask":
return (
<>
<div style={headerStyle}>
{toolIcon("check-all")}
<span style={{ fontWeight: "bold" }}>{t("chat:subtasks.wantsToFinish")}</span>
</div>
<div
style={{
marginTop: "4px",
backgroundColor: "var(--vscode-editor-background)",
border: "1px solid var(--vscode-badge-background)",
borderRadius: "4px",
overflow: "hidden",
marginBottom: "8px",
}}>
<div
style={{
padding: "9px 10px 9px 14px",
backgroundColor: "var(--vscode-badge-background)",
borderBottom: "1px solid var(--vscode-editorGroup-border)",
fontWeight: "bold",
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-badge-foreground)",
display: "flex",
alignItems: "center",
gap: "6px",
}}>
<span className="codicon codicon-check"></span>
{t("chat:subtasks.completionContent")}
</div>
<div style={{ padding: "12px 16px", backgroundColor: "var(--vscode-editor-background)" }}>
<MarkdownBlock markdown={t("chat:subtasks.completionInstructions")} />
</div>
</div>
</>
)
case "generateImage":
return (
<>
<div style={headerStyle}>
{tool.isProtected ? (
<span
className="codicon codicon-lock"
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
/>
) : (
toolIcon("file-media")
)}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask"
? tool.isProtected
? t("chat:fileOperations.wantsToGenerateImageProtected")
: tool.isOutsideWorkspace
? t("chat:fileOperations.wantsToGenerateImageOutsideWorkspace")
: t("chat:fileOperations.wantsToGenerateImage")
: t("chat:fileOperations.didGenerateImage")}
</span>
</div>
{message.type === "ask" && (
<CodeAccordian
path={tool.path}
code={tool.content}
language="text"
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
/>
)}
</>
)
default:
return null
}
}
switch (message.type) {
case "say":
switch (message.say) {
case "diff_error":
return (
<div>
<div
style={{
marginTop: "0px",
overflow: "hidden",
marginBottom: "8px",
}}>
<div
style={{
borderBottom: isDiffErrorExpanded
? "1px solid var(--vscode-editorGroup-border)"
: "none",
fontWeight: "normal",
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-editor-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
}}
onClick={() => setIsDiffErrorExpanded(!isDiffErrorExpanded)}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "10px",
flexGrow: 1,
}}>
<span
className="codicon codicon-warning"
style={{
color: "var(--vscode-editorWarning-foreground)",
opacity: 0.8,
fontSize: 16,
marginBottom: "-1.5px",
}}></span>
<span style={{ fontWeight: "bold" }}>{t("chat:diffError.title")}</span>
</div>
<div style={{ display: "flex", alignItems: "center" }}>
<VSCodeButton
appearance="icon"
style={{
padding: "3px",
height: "24px",
marginRight: "4px",
color: "var(--vscode-editor-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "transparent",
}}
onClick={(e) => {
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)
}
})
}}>
<span
className={`codicon codicon-${showCopySuccess ? "check" : "copy"}`}></span>
</VSCodeButton>
<span
className={`codicon codicon-chevron-${isDiffErrorExpanded ? "up" : "down"}`}></span>
</div>
</div>
{isDiffErrorExpanded && (
<div
style={{
padding: "8px",
backgroundColor: "var(--vscode-editor-background)",
borderTop: "none",
}}>
<CodeBlock source={message.text || ""} language="xml" />
</div>
)}
</div>
</div>
)
case "subtask_result":
return (
<div>
<div
style={{
marginTop: "0px",
backgroundColor: "var(--vscode-badge-background)",
border: "1px solid var(--vscode-badge-background)",
borderRadius: "0 0 4px 4px",
overflow: "hidden",
marginBottom: "8px",
}}>
<div
style={{
padding: "9px 10px 9px 14px",
backgroundColor: "var(--vscode-badge-background)",
borderBottom: "1px solid var(--vscode-editorGroup-border)",
fontWeight: "bold",
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-badge-foreground)",
display: "flex",
alignItems: "center",
gap: "6px",
}}>
<span className="codicon codicon-arrow-left"></span>
{t("chat:subtasks.resultContent")}
</div>
<div
style={{
padding: "12px 16px",
backgroundColor: "var(--vscode-editor-background)",
}}>
<MarkdownBlock markdown={message.text} />
</div>
</div>
</div>
)
case "reasoning":
return (
<ReasoningBlock
content={message.text || ""}
elapsed={isLast && isStreaming ? Date.now() - message.ts : undefined}
isCollapsed={reasoningCollapsed}
onToggleCollapse={() => setReasoningCollapsed(!reasoningCollapsed)}
/>
)
case "api_req_started":
return (
<>
<div
style={{
...headerStyle,
marginBottom:
((cost === null || cost === undefined) && apiRequestFailedMessage) ||
apiReqStreamingFailedMessage
? 10
: 0,
justifyContent: "space-between",
cursor: "pointer",
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}
onClick={handleToggleExpand}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", flexGrow: 1 }}>
{icon}
{title}
<VSCodeBadge
style={{ opacity: cost !== null && cost !== undefined && cost > 0 ? 1 : 0 }}>
${Number(cost || 0)?.toFixed(4)}
</VSCodeBadge>
</div>
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
</div>
{(((cost === null || cost === undefined) && apiRequestFailedMessage) ||
apiReqStreamingFailedMessage) && (
<>
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
<>
<br />
<br />
{t("chat:powershell.issues")}{" "}
<a
href="https://github.com/cline/cline/wiki/TroubleShooting-%E2%80%90-%22PowerShell-is-not-recognized-as-an-internal-or-external-command%22"
style={{ color: "inherit", textDecoration: "underline" }}>
troubleshooting guide
</a>
.
</>
)}
</p>
</>
)}
{isExpanded && (
<div style={{ marginTop: "10px" }}>
<CodeAccordian
code={safeJsonParse<any>(message.text)?.request}
language="markdown"
isExpanded={true}
onToggleExpand={handleToggleExpand}
/>
</div>
)}
</>
)
case "api_req_finished":
return null // we should never see this message type
case "text":
return (
<div>
<Markdown markdown={message.text} partial={message.partial} />
{message.images && message.images.length > 0 && (
<div style={{ marginTop: "10px" }}>
{message.images.map((image, index) => (
<ImageBlock key={index} imageData={image} />
))}
</div>
)}
</div>
)
case "user_feedback":
return (
<div className="bg-vscode-editor-background border rounded-xs p-1 overflow-hidden whitespace-pre-wrap">
<div className="flex justify-between">
<div className="flex-grow px-2 py-1 wrap-anywhere">
<Mention text={message.text} withShadow />
</div>
<div className="flex">
<Button
variant="ghost"
size="icon"
className="shrink-0"
disabled={isStreaming}
onClick={(e) => {
e.stopPropagation()
vscode.postMessage({ type: "deleteMessage", value: message.ts })
}}>
<span className="codicon codicon-trash" />
</Button>
</div>
</div>
{message.images && message.images.length > 0 && (
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
)}
</div>
)
case "user_feedback_diff":
const tool = safeJsonParse<ClineSayTool>(message.text)
return (
<div style={{ marginTop: -10, width: "100%" }}>
<CodeAccordian
code={tool?.diff}
language="diff"
isFeedback={true}
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
/>
</div>
)
case "error":
return (
<>
{title && (
<div style={headerStyle}>
{icon}
{title}
</div>
)}
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
</>
)
case "completion_result":
return (
<>
<div style={headerStyle}>
{icon}
{title}
</div>
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
<Markdown markdown={message.text} />
</div>
</>
)
case "shell_integration_warning":
return <CommandExecutionError />
case "checkpoint_saved":
return (
<CheckpointSaved
ts={message.ts!}
commitHash={message.text!}
currentHash={currentCheckpoint}
checkpoint={message.checkpoint}
/>
)
case "condense_context":
if (message.partial) {
return <CondensingContextRow />
}
return message.contextCondense ? <ContextCondenseRow {...message.contextCondense} /> : null
case "condense_context_error":
return <CondenseContextErrorRow errorText={message.text} />
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 <div>Error displaying search results.</div>
}
const { results = [] } = parsed?.content || {}
return <CodebaseSearchResultsDisplay results={results} />
case "user_edit_todos":
return <UpdateTodoListToolBlock userEdited onChange={() => {}} />
case "image":
// Parse the JSON to get imageUri and imagePath
const imageInfo = safeJsonParse<{ imageUri: string; imagePath: string }>(message.text || "{}")
if (!imageInfo) {
return null
}
return (
<div style={{ marginTop: "10px" }}>
<ImageBlock imageUri={imageInfo.imageUri} imagePath={imageInfo.imagePath} />
</div>
)
default:
return (
<>
{title && (
<div style={headerStyle}>
{icon}
{title}
</div>
)}
<div style={{ paddingTop: 10 }}>
<Markdown markdown={message.text} partial={message.partial} />
</div>
</>
)
}
case "ask":
switch (message.ask) {
case "mistake_limit_reached":
return (
<>
<div style={headerStyle}>
{icon}
{title}
</div>
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
</>
)
case "command":
return (
<CommandExecution
executionId={message.ts.toString()}
text={message.text}
icon={icon}
title={title}
/>
)
case "use_mcp_server":
// Parse the message text to get the MCP server request
const messageJson = safeJsonParse<any>(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 (
<>
<div style={headerStyle}>
{icon}
{title}
</div>
<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs p-2 mt-2">
{useMcpServer.type === "access_mcp_resource" && (
<McpResourceRow
item={{
// Use the matched resource/template details, with fallbacks
...(findMatchingResourceOrTemplate(
useMcpServer.uri || "",
server?.resources,
server?.resourceTemplates,
) || {
name: "",
mimeType: "",
description: "",
}),
// Always use the actual URI from the request
uri: useMcpServer.uri || "",
}}
/>
)}
{useMcpServer.type === "use_mcp_tool" && (
<McpExecution
executionId={message.ts.toString()}
text={useMcpServer.arguments !== "{}" ? useMcpServer.arguments : undefined}
serverName={useMcpServer.serverName}
toolName={useMcpServer.toolName}
isArguments={true}
server={server}
useMcpServer={useMcpServer}
alwaysAllowMcp={alwaysAllowMcp}
/>
)}
</div>
</>
)
case "completion_result":
if (message.text) {
return (
<div>
<div style={headerStyle}>
{icon}
{title}
</div>
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
<Markdown markdown={message.text} partial={message.partial} />
</div>
</div>
)
} else {
return null // Don't render anything when we get a completion_result ask without text
}
case "followup":
return (
<>
{title && (
<div style={headerStyle}>
{icon}
{title}
</div>
)}
<div style={{ paddingTop: 10, paddingBottom: 15 }}>
<Markdown
markdown={message.partial === true ? message?.text : followUpData?.question}
/>
</div>
<FollowUpSuggest
suggestions={followUpData?.suggest}
onSuggestionClick={onSuggestionClick}
ts={message?.ts}
onCancelAutoApproval={onFollowUpUnmount}
isAnswered={isFollowUpAnswered}
/>
</>
)
case "auto_approval_max_req_reached": {
return <AutoApprovedRequestLimitWarning message={message} />
}
default:
return null
}
}
}