diff --git a/webview-ui/src/components/chat/BrowserSessionRow.tsx b/webview-ui/src/components/chat/BrowserSessionRow.tsx index 7138f745962..842c9ec6407 100644 --- a/webview-ui/src/components/chat/BrowserSessionRow.tsx +++ b/webview-ui/src/components/chat/BrowserSessionRow.tsx @@ -419,85 +419,87 @@ interface BrowserSessionRowContentProps extends Omit { - const { t } = useTranslation() - const headerStyle: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: "10px", - marginBottom: "10px", - } +const BrowserSessionRowContent = memo( + ({ + message, + isExpanded, + onToggleExpand, + lastModifiedMessage, + isLast, + setMaxActionHeight, + isStreaming, + }: BrowserSessionRowContentProps) => { + const { t } = useTranslation() + const headerStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: "10px", + marginBottom: "10px", + } - switch (message.type) { - case "say": - switch (message.say) { - case "api_req_started": - case "text": - return ( -
- { - if (message.say === "api_req_started") { - setMaxActionHeight(0) - } - onToggleExpand(message.ts) - }} - lastModifiedMessage={lastModifiedMessage} - isLast={isLast} - isStreaming={isStreaming} + switch (message.type) { + case "say": + switch (message.say) { + case "api_req_started": + case "text": + return ( +
+ { + if (message.say === "api_req_started") { + setMaxActionHeight(0) + } + onToggleExpand(message.ts) + }} + lastModifiedMessage={lastModifiedMessage} + isLast={isLast} + isStreaming={isStreaming} + /> +
+ ) + + case "browser_action": + const browserAction = JSON.parse(message.text || "{}") as ClineSayBrowserAction + return ( + -
- ) - - case "browser_action": - const browserAction = JSON.parse(message.text || "{}") as ClineSayBrowserAction - return ( - - ) - - default: - return null - } + ) - case "ask": - switch (message.ask) { - case "browser_action_launch": - return ( - <> -
- {t("chat:browser.sessionStarted")} -
-
- -
- - ) + default: + return null + } - default: - return null - } - } -} + case "ask": + switch (message.ask) { + case "browser_action_launch": + return ( + <> +
+ {t("chat:browser.sessionStarted")} +
+
+ +
+ + ) + + default: + return null + } + } + }, +) const BrowserActionBox = ({ action, diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 05005e46a1a..8d37b757b81 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -74,1165 +74,1192 @@ const ChatRow = memo( export default ChatRow -export const ChatRowContent = ({ - message, - lastModifiedMessage, - isExpanded, - isLast, - isStreaming, - onToggleExpand, - onSuggestionClick, -}: 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() +export const ChatRowContent = memo( + ({ + message, + lastModifiedMessage, + isExpanded, + isLast, + isStreaming, + onToggleExpand, + onSuggestionClick, + }: 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() - const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => { - if (message.text !== null && message.text !== undefined && message.say === "api_req_started") { - const info: ClineApiReqInfo = JSON.parse(message.text) - return [info.cost, info.cancelReason, info.streamingFailedMessage] - } + const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => { + if (message.text !== null && message.text !== undefined && message.say === "api_req_started") { + const info: ClineApiReqInfo = JSON.parse(message.text) + return [info.cost, info.cancelReason, info.streamingFailedMessage] + } - return [undefined, undefined, undefined] - }, [message.text, message.say]) + 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 + // 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 isCommandExecuting = + isLast && + lastModifiedMessage?.ask === "command" && + lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING) - const isMcpServerResponding = isLast && lastModifiedMessage?.say === "mcp_server_request_started" + const isMcpServerResponding = isLast && lastModifiedMessage?.say === "mcp_server_request_started" - const type = message.type === "ask" ? message.ask : message.say + 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 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 ? ( - - ) : ( + const [icon, title] = useMemo(() => { + switch (type) { + case "error": + return [ - ), - {t("chat:runCommand.title")}:, - ] - case "use_mcp_server": - const mcpServerUse = JSON.parse(message.text || "{}") as ClineAskUseMcpServer - return [ - isMcpServerResponding ? ( - - ) : ( + className="codicon codicon-error" + style={{ color: errorColor, marginBottom: "-1.5px" }}>, + {t("chat:error")}, + ] + case "mistake_limit_reached": + return [ - ), - - {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) => ( -
+ className="codicon codicon-error" + style={{ color: errorColor, marginBottom: "-1.5px" }}>, + {t("chat:troubleMessage")}, + ] + case "command": + return [ + isCommandExecuting ? ( + + ) : ( + + ), + {t("chat:runCommand.title")}:, + ] + case "use_mcp_server": + const mcpServerUse = JSON.parse(message.text || "{}") as ClineAskUseMcpServer + 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) - ) : ( + width: 16, + height: 16, + display: "flex", + alignItems: "center", + justifyContent: "center", + }}> + +
+ ) + 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) - ) - ) : 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")} + + ), + 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")} - ) - ) : 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", - } - - const pStyle: React.CSSProperties = { - margin: 0, - whiteSpace: "pre-wrap", - wordBreak: "break-word", - overflowWrap: "anywhere", - } + ), + ] + case "followup": + return [ + , + + {t("chat:questions.hasQuestion")} + , + ] + default: + return [null, null] + } + }, [ + type, + isCommandExecuting, + message, + isMcpServerResponding, + apiReqCancelReason, + cost, + apiRequestFailedMessage, + t, + ]) - const tool = useMemo(() => { - if (message.ask === "tool" || message.say === "tool") { - return JSON.parse(message.text || "{}") as ClineSayTool + const headerStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: "10px", + marginBottom: "10px", } - return null - }, [message.ask, message.say, message.text]) - const followUpData = useMemo(() => { - if (message.type === "ask" && message.ask === "followup" && !message.partial) { - return JSON.parse(message.text || "{}") + const pStyle: React.CSSProperties = { + margin: 0, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + overflowWrap: "anywhere", } - return null - }, [message.type, message.ask, message.partial, message.text]) - if (tool) { - const toolIcon = (name: string) => ( - - ) + const tool = useMemo(() => { + if (message.ask === "tool" || message.say === "tool") { + return JSON.parse(message.text || "{}") as ClineSayTool + } + return null + }, [message.ask, message.say, message.text]) - switch (tool.tool) { - case "editedExistingFile": - case "appliedDiff": - return ( - <> -
- {toolIcon(tool.tool === "appliedDiff" ? "diff" : "edit")} - - {tool.isOutsideWorkspace - ? t("chat:fileOperations.wantsToEditOutsideWorkspace") - : t("chat:fileOperations.wantsToEdit")} - -
- - - ) - case "newFileCreated": - return ( - <> -
- {toolIcon("new-file")} - {t("chat:fileOperations.wantsToCreate")} -
- - - ) - case "readFile": - return ( - <> -
- {toolIcon("file-code")} - - {message.type === "ask" - ? tool.isOutsideWorkspace - ? t("chat:fileOperations.wantsToReadOutsideWorkspace") - : t("chat:fileOperations.wantsToRead") - : t("chat:fileOperations.didRead")} - -
- {/* */} -
-
{ - vscode.postMessage({ type: "openFile", text: tool.content }) - }}> - {tool.path?.startsWith(".") && .} - - {removeLeadingNonAlphanumeric(tool.path ?? "") + "\u200E"} - {tool.reason} + const followUpData = useMemo(() => { + if (message.type === "ask" && message.ask === "followup" && !message.partial) { + return JSON.parse(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": + return ( + <> +
+ {toolIcon(tool.tool === "appliedDiff" ? "diff" : "edit")} + + {tool.isOutsideWorkspace + ? t("chat:fileOperations.wantsToEditOutsideWorkspace") + : t("chat:fileOperations.wantsToEdit")} -
-
-
- - ) - case "fetchInstructions": - return ( - <> -
- {toolIcon("file-code")} - {t("chat:instructions.wantsToFetch")} -
- - - ) - case "listFilesTopLevel": - return ( - <> -
- {toolIcon("folder-opened")} - - {message.type === "ask" - ? t("chat:directoryOperations.wantsToViewTopLevel") - : t("chat:directoryOperations.didViewTopLevel")} - -
- - - ) - case "listFilesRecursive": - return ( - <> -
- {toolIcon("folder-opened")} - - {message.type === "ask" - ? t("chat:directoryOperations.wantsToViewRecursive") - : t("chat:directoryOperations.didViewRecursive")} - -
- - - ) - case "listCodeDefinitionNames": - return ( - <> -
- {toolIcon("file-code")} - - {message.type === "ask" - ? t("chat:directoryOperations.wantsToViewDefinitions") - : t("chat:directoryOperations.didViewDefinitions")} - -
- + + ) + case "newFileCreated": + return ( + <> +
+ {toolIcon("new-file")} + {t("chat:fileOperations.wantsToCreate")} +
+ + + ) + case "readFile": + return ( + <> +
+ {toolIcon("file-code")} + + {message.type === "ask" + ? tool.isOutsideWorkspace + ? t("chat:fileOperations.wantsToReadOutsideWorkspace") + : t("chat:fileOperations.wantsToRead") + : t("chat:fileOperations.didRead")} + +
+ {/* - - ) - 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")} -
-
- -
-
- - ) - default: - return null - } - } - - switch (message.type) { - case "say": - switch (message.say) { - case "diff_error": - return ( -
+ /> */}
setIsDiffErrorExpanded(!isDiffErrorExpanded)}> -
{ + vscode.postMessage({ type: "openFile", text: tool.content }) + }}> + {tool.path?.startsWith(".") && .} + - - {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) - } - }) - }}> - - - -
+ {removeLeadingNonAlphanumeric(tool.path ?? "") + "\u200E"} + {tool.reason} + +
+
- {isDiffErrorExpanded && ( -
- -
- )}
-
+ ) - case "subtask_result": + case "fetchInstructions": return ( -
-
-
- - {t("chat:subtasks.resultContent")} -
-
- -
+ <> +
+ {toolIcon("file-code")} + {t("chat:instructions.wantsToFetch")}
-
+ + ) - case "reasoning": + case "listFilesTopLevel": return ( - setReasoningCollapsed(!reasoningCollapsed)} - /> + <> +
+ {toolIcon("folder-opened")} + + {message.type === "ask" + ? t("chat:directoryOperations.wantsToViewTopLevel") + : t("chat:directoryOperations.didViewTopLevel")} + +
+ + ) - case "api_req_started": + case "listFilesRecursive": return ( <> -
-
- {icon} - {title} - 0 ? 1 : 0 }}> - ${Number(cost || 0)?.toFixed(4)} - -
- +
+ {toolIcon("folder-opened")} + + {message.type === "ask" + ? t("chat:directoryOperations.wantsToViewRecursive") + : t("chat:directoryOperations.didViewRecursive")} +
- {(((cost === null || cost === undefined) && apiRequestFailedMessage) || - apiReqStreamingFailedMessage) && ( - <> -

- {apiRequestFailedMessage || apiReqStreamingFailedMessage} - {apiRequestFailedMessage?.toLowerCase().includes("powershell") && ( - <> -
-
- {t("chat:powershell.issues")}{" "} - - troubleshooting guide - - . - - )} -

- - {/* {apiProvider === "" && ( -
- - - Uh-oh, this could be a problem on end. We've been alerted and - will resolve this ASAP. You can also{" "} - - contact us - - . - -
- )} */} - - )} - - {isExpanded && ( -
- -
- )} + ) - case "api_req_finished": - return null // we should never see this message type - case "text": - return ( -
- -
- ) - case "user_feedback": + case "listCodeDefinitionNames": return ( -
-
- - {highlightMentions(message.text)} + <> +
+ {toolIcon("file-code")} + + {message.type === "ask" + ? t("chat:directoryOperations.wantsToViewDefinitions") + : t("chat:directoryOperations.didViewDefinitions")} - { - e.stopPropagation() - vscode.postMessage({ - type: "deleteMessage", - value: message.ts, - }) - }}> - -
- {message.images && message.images.length > 0 && ( - - )} -
- ) - case "user_feedback_diff": - const tool = JSON.parse(message.text || "{}") as ClineSayTool - return ( -
-
+ ) - case "error": + case "searchFiles": return ( <> - {title && ( -
- {icon} - {title} -
- )} -

{message.text}

+
+ {toolIcon("search")} + + {message.type === "ask" ? ( + {tool.regex} }} + values={{ regex: tool.regex }} + /> + ) : ( + {tool.regex} }} + values={{ regex: tool.regex }} + /> + )} + +
+ ) - case "completion_result": + case "switchMode": return ( <>
- {icon} - {title} -
-
- + {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 "shell_integration_warning": + case "newTask": return ( <> +
+ {toolIcon("tasklist")} + + {tool.mode} }} + values={{ mode: tool.mode }} + /> + +
-
- - - {t("chat:shellIntegration.unavailable")} - +
+ + {t("chat:subtasks.newTaskContent")}
-
- {message.text} -
-
- • {t("chat:shellIntegration.checkSettings")} -
- • {t("chat:shellIntegration.updateVSCode")} ( - CMD/CTRL + Shift + P → "Update") -
- • {t("chat:shellIntegration.supportedShell")} ( - CMD/CTRL + Shift + P → "Terminal: Select Default Profile") -
-
- - {t("chat:shellIntegration.troubleshooting")} - +
+
) - case "mcp_server_response": + case "finishTask": return ( <> -
+
+ {toolIcon("check-all")} + {t("chat:subtasks.wantsToFinish")} +
+
- {t("chat:response")} + + {t("chat:subtasks.completionContent")}
- -
- - ) - case "checkpoint_saved": - return ( - - ) - default: - return ( - <> - {title && ( -
- {icon} - {title} +
+
- )} -
-
) + default: + return null } - case "ask": - switch (message.ask) { - case "mistake_limit_reached": - return ( - <> -
- {icon} - {title} -
-

{message.text}

- - ) - case "command": - const splitMessage = (text: string) => { - const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING) - if (outputIndex === -1) { - return { command: text, output: "" } - } - return { - command: text.slice(0, outputIndex).trim(), - output: text - .slice(outputIndex + COMMAND_OUTPUT_STRING.length) - .trim() - .split("") - .map((char) => { - switch (char) { - case "\t": - return "→ " - case "\b": - return "⌫" - case "\f": - return "⏏" - case "\v": - return "⇳" - default: - return char - } - }) - .join(""), - } - } + } - const { command, output } = splitMessage(message.text || "") - return ( - <> -
- {icon} - {title} -
- {/* 0} - /> */} -
- - {output.length > 0 && ( -
+ switch (message.type) { + case "say": + switch (message.say) { + case "diff_error": + return ( +
+
+
setIsDiffErrorExpanded(!isDiffErrorExpanded)}>
- {t("chat:commandOutput")} + className="codicon codicon-warning" + style={{ + color: "var(--vscode-editorWarning-foreground)", + opacity: 0.8, + fontSize: 16, + marginBottom: "-1.5px", + }}> + {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) + } + }) + }}> + + +
- {isExpanded && }
- )} + {isDiffErrorExpanded && ( +
+ +
+ )} +
- - ) - case "use_mcp_server": - const useMcpServer = JSON.parse(message.text || "{}") as ClineAskUseMcpServer - const server = mcpServers.find((server) => server.name === useMcpServer.serverName) - return ( - <> -
- {icon} - {title} + ) + 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 + + . + + )} +

+ {/* {apiProvider === "" && ( +
+ + + Uh-oh, this could be a problem on end. We've been alerted and + will resolve this ASAP. You can also{" "} + + contact us + + . + +
+ )} */} + + )} + + {isExpanded && ( +
+ +
+ )} + + ) + case "api_req_finished": + return null // we should never see this message type + case "text": + return ( +
+ +
+ ) + case "user_feedback": + return (
- {useMcpServer.type === "access_mcp_resource" && ( - + + {highlightMentions(message.text)} + + - )} - - {useMcpServer.type === "use_mcp_tool" && ( - <> -
e.stopPropagation()}> - tool.name === useMcpServer.toolName, - )?.description || "", - alwaysAllow: - server?.tools?.find( - (tool) => tool.name === useMcpServer.toolName, - )?.alwaysAllow || false, - }} - serverName={useMcpServer.serverName} - alwaysAllowMcp={alwaysAllowMcp} - /> -
- {useMcpServer.arguments && useMcpServer.arguments !== "{}" && ( -
-
- {t("chat:arguments")} -
- -
- )} - + disabled={isStreaming} + onClick={(e) => { + e.stopPropagation() + vscode.postMessage({ + type: "deleteMessage", + value: message.ts, + }) + }}> + +
+
+ {message.images && message.images.length > 0 && ( + )}
- - ) - case "completion_result": - if (message.text) { + ) + case "user_feedback_diff": + const tool = JSON.parse(message.text || "{}") as ClineSayTool return ( -
+
+ +
+ ) + case "error": + return ( + <> + {title && ( +
+ {icon} + {title} +
+ )} +

{message.text}

+ + ) + case "completion_result": + return ( + <>
{icon} {title}
+ +
+ + ) + case "shell_integration_warning": + return ( + <> +
+
+ + + {t("chat:shellIntegration.unavailable")} + +
+
+ {message.text} +
+
+ • {t("chat:shellIntegration.checkSettings")} +
+ • {t("chat:shellIntegration.updateVSCode")} ( + CMD/CTRL + Shift + P → "Update") +
+ • {t("chat:shellIntegration.supportedShell")} ( + CMD/CTRL + Shift + P → "Terminal: Select Default Profile") +
+
+ + {t("chat:shellIntegration.troubleshooting")} + +
+
+ + ) + case "mcp_server_response": + return ( + <> +
+
+ {t("chat:response")} +
+ +
+ + ) + case "checkpoint_saved": + return ( + + ) + default: + return ( + <> + {title && ( +
+ {icon} + {title} +
+ )} +
-
+ ) - } else { - return null // Don't render anything when we get a completion_result ask without text - } - case "followup": - return ( - <> - {title && ( + } + case "ask": + switch (message.ask) { + case "mistake_limit_reached": + return ( + <>
{icon} {title}
- )} -
- {message.text}

+ + ) + case "command": + const splitMessage = (text: string) => { + const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING) + if (outputIndex === -1) { + return { command: text, output: "" } + } + return { + command: text.slice(0, outputIndex).trim(), + output: text + .slice(outputIndex + COMMAND_OUTPUT_STRING.length) + .trim() + .split("") + .map((char) => { + switch (char) { + case "\t": + return "→ " + case "\b": + return "⌫" + case "\f": + return "⏏" + case "\v": + return "⇳" + default: + return char + } + }) + .join(""), + } + } + + const { command, output } = splitMessage(message.text || "") + return ( + <> +
+ {icon} + {title} +
+ {/* 0} + /> */} +
+ + {output.length > 0 && ( +
+
+ + {t("chat:commandOutput")} +
+ {isExpanded && } +
+ )} +
+ + ) + case "use_mcp_server": + const useMcpServer = JSON.parse(message.text || "{}") as ClineAskUseMcpServer + const server = mcpServers.find((server) => server.name === useMcpServer.serverName) + return ( + <> +
+ {icon} + {title} +
+ +
+ {useMcpServer.type === "access_mcp_resource" && ( + + )} + + {useMcpServer.type === "use_mcp_tool" && ( + <> +
e.stopPropagation()}> + tool.name === useMcpServer.toolName, + )?.description || "", + alwaysAllow: + server?.tools?.find( + (tool) => tool.name === useMcpServer.toolName, + )?.alwaysAllow || false, + }} + serverName={useMcpServer.serverName} + alwaysAllowMcp={alwaysAllowMcp} + /> +
+ {useMcpServer.arguments && useMcpServer.arguments !== "{}" && ( +
+
+ {t("chat:arguments")} +
+ +
+ )} + + )} +
+ + ) + 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} +
+ )} +
+ +
+ -
- - - ) - default: - return null - } - } -} + + ) + default: + return null + } + } + }, +) export const ProgressIndicator = () => (
(undefined) const [secondaryButtonText, setSecondaryButtonText] = useState(undefined) const [didClickCancel, setDidClickCancel] = useState(false) - const virtuosoRef = useRef(null) const [expandedRows, setExpandedRows] = useState>({}) const scrollContainerRef = useRef(null) const disableAutoScrollRef = useRef(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [isAtBottom, setIsAtBottom] = useState(false) const lastTtsRef = useRef("") + const [wasStreaming, setWasStreaming] = useState(false) const [showCheckpointWarning, setShowCheckpointWarning] = useState(false) @@ -874,77 +873,58 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie () => debounce( () => { - virtuosoRef.current?.scrollTo({ - top: Number.MAX_SAFE_INTEGER, - behavior: "smooth", - }) + const container = scrollContainerRef.current + if (container && typeof container.scrollTo === "function") { + container.scrollTo({ + top: container.scrollHeight, + behavior: "smooth", + }) + } }, - 10, - { immediate: true }, + 10, // Keep debounce low for responsiveness + { immediate: false }, // Let height changes settle before scrolling ), - [], + [scrollContainerRef], ) const scrollToBottomAuto = useCallback(() => { - virtuosoRef.current?.scrollTo({ - top: Number.MAX_SAFE_INTEGER, - behavior: "auto", // instant causes crash - }) - }, []) + const container = scrollContainerRef.current + if (container) { + // Use requestAnimationFrame to make sure scroll happens after DOM updates + requestAnimationFrame(() => { + container.scrollTop = container.scrollHeight + }) + } + }, [scrollContainerRef]) // scroll when user toggles certain rows const toggleRowExpansion = useCallback( (ts: number) => { const isCollapsing = expandedRows[ts] ?? false - const lastGroup = groupedMessages.at(-1) - const isLast = Array.isArray(lastGroup) ? lastGroup[0].ts === ts : lastGroup?.ts === ts - const secondToLastGroup = groupedMessages.at(-2) - const isSecondToLast = Array.isArray(secondToLastGroup) - ? secondToLastGroup[0].ts === ts - : secondToLastGroup?.ts === ts - - const isLastCollapsedApiReq = - isLast && - !Array.isArray(lastGroup) && // Make sure it's not a browser session group - lastGroup?.say === "api_req_started" && - !expandedRows[lastGroup.ts] setExpandedRows((prev) => ({ ...prev, [ts]: !prev[ts], })) - // disable auto scroll when user expands row - if (!isCollapsing) { + // disable auto scroll when user expands/collapses a row unless already at bottom + if (!isAtBottom) { disableAutoScrollRef.current = true + setShowScrollToBottom(true) // Show button immediately if not at bottom } + // If collapsing and we were at the bottom, scroll to maintain bottom position after height change if (isCollapsing && isAtBottom) { - const timer = setTimeout(() => { + // Use timeout to allow DOM to update height before scrolling + setTimeout(() => { scrollToBottomAuto() }, 0) - return () => clearTimeout(timer) - } else if (isLast || isSecondToLast) { - if (isCollapsing) { - if (isSecondToLast && !isLastCollapsedApiReq) { - return - } - const timer = setTimeout(() => { - scrollToBottomAuto() - }, 0) - return () => clearTimeout(timer) - } else { - const timer = setTimeout(() => { - virtuosoRef.current?.scrollToIndex({ - index: groupedMessages.length - (isLast ? 1 : 2), - align: "start", - }) - }, 0) - return () => clearTimeout(timer) - } } + + // We rely on handleRowHeightChange and the general scroll logic for now. + // If an item expands and pushes content off-screen, the user might need to scroll manually. }, - [groupedMessages, expandedRows, scrollToBottomAuto, isAtBottom], + [expandedRows, scrollToBottomAuto, isAtBottom], ) const handleRowHeightChange = useCallback( @@ -971,15 +951,53 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie } }, [groupedMessages.length, scrollToBottomSmooth]) - const handleWheel = useCallback((event: Event) => { - const wheelEvent = event as WheelEvent - if (wheelEvent.deltaY && wheelEvent.deltaY < 0) { - if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) { - // user scrolled up - disableAutoScrollRef.current = true + const handleScroll = useCallback(() => { + const container = scrollContainerRef.current + if (container) { + const { scrollTop, scrollHeight, clientHeight } = container + // Add a small threshold for being "at bottom" + const atBottom = scrollHeight - scrollTop - clientHeight < 10 + setIsAtBottom(atBottom) + + if (atBottom) { + disableAutoScrollRef.current = false } + // Note: We don't automatically set disableAutoScrollRef to true on scroll up here. + // It's set when the user explicitly interacts (wheel, toggle expansion). + + setShowScrollToBottom(disableAutoScrollRef.current && !atBottom) } - }, []) + }, [scrollContainerRef]) + + const handleWheel = useCallback( + (event: Event) => { + const wheelEvent = event as WheelEvent + const container = scrollContainerRef.current + // Check if the scroll event is happening within scroll container + if (container?.contains(wheelEvent.target as Node)) { + // User scrolled up + if (wheelEvent.deltaY < 0 && container.scrollTop > 0) { + disableAutoScrollRef.current = true + setShowScrollToBottom(true) // Show button immediately on scroll up + } + // User scrolled down but not to the absolute bottom yet + else if (wheelEvent.deltaY > 0) { + const { scrollTop, scrollHeight, clientHeight } = container + const atBottom = scrollHeight - scrollTop - clientHeight < 10 + if (!atBottom) { + // If user scrolls down manually, keep auto-scroll disabled until they reach the bottom + disableAutoScrollRef.current = true + setShowScrollToBottom(true) + } else { + // Reached bottom via manual scroll, re-enable auto-scroll + disableAutoScrollRef.current = false + setShowScrollToBottom(false) + } + } + } + }, + [scrollContainerRef], + ) useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance // Effect to handle showing the checkpoint warning after a delay @@ -1037,64 +1055,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie const placeholderText = task ? t("chat:typeMessage") : t("chat:typeTask") - const itemContent = useCallback( - (index: number, messageOrGroup: ClineMessage | ClineMessage[]) => { - // browser session group - if (Array.isArray(messageOrGroup)) { - return ( - expandedRows[messageTs] ?? false} - onToggleExpand={(messageTs: number) => { - setExpandedRows((prev) => ({ - ...prev, - [messageTs]: !prev[messageTs], - })) - }} - /> - ) - } - - // regular message - return ( - toggleRowExpansion(messageOrGroup.ts)} - lastModifiedMessage={modifiedMessages.at(-1)} - isLast={index === groupedMessages.length - 1} - onHeightChange={handleRowHeightChange} - isStreaming={isStreaming} - onSuggestionClick={(answer: string, event?: React.MouseEvent) => { - if (event?.shiftKey) { - // Always append to existing text, don't overwrite - setInputValue((currentValue) => { - return currentValue !== "" ? `${currentValue} \n${answer}` : answer - }) - } else { - handleSendMessage(answer, []) - } - }} - /> - ) - }, - [ - expandedRows, - modifiedMessages, - groupedMessages.length, - handleRowHeightChange, - isStreaming, - toggleRowExpansion, - handleSendMessage, - ], - ) - useEffect(() => { // Only proceed if we have an ask and buttons are enabled if (!clineAsk || !enableButtons) return @@ -1162,6 +1122,21 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie } }, [handleKeyDown]) + // Defining handleSuggestionClick outside the map loop and memo-ing it here + const handleSuggestionClick = useCallback( + (answer: string, event?: React.MouseEvent) => { + if (event?.shiftKey) { + // Always append to existing text, don't overwrite + setInputValue((currentValue) => { + return currentValue !== "" ? `${currentValue} \n${answer}` : answer + }) + } else { + handleSendMessage(answer, []) + } + }, + [handleSendMessage, setInputValue], // handleSendMessage is memoized, setInputValue is stable + ) + return (
)} - {/* + {/* // Flex layout explanation: // 1. Content div above uses flex: "1 1 0" to: - // - Grow to fill available space (flex-grow: 1) + // - Grow to fill available space (flex-grow: 1) // - Shrink when AutoApproveMenu needs space (flex-shrink: 1) // - Start from zero size (flex-basis: 0) to ensure proper distribution // minHeight: 0 allows it to shrink below its content height // // 2. AutoApproveMenu uses flex: "0 1 auto" to: // - Not grow beyond its content (flex-grow: 0) - // - Shrink when viewport is small (flex-shrink: 1) + // - Shrink when viewport is small (flex-shrink: 1) // - Use its content size as basis (flex-basis: auto) // This ensures it takes its natural height when there's space // but becomes scrollable when the viewport is too small @@ -1244,32 +1219,57 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie {task && ( <> -
-
, // Add empty padding at the bottom - }} - // increasing top by 3_000 to prevent jumping around when user collapses a row - increaseViewportBy={{ top: 3_000, bottom: Number.MAX_SAFE_INTEGER }} // hack to make sure the last message is always rendered to get truly perfect scroll to bottom animation when new messages are added (Number.MAX_SAFE_INTEGER is safe for arithmetic operations, which is all virtuoso uses this value for in src/sizeRangeSystem.ts) - data={groupedMessages} // messages is the raw format returned by extension, modifiedMessages is the manipulated structure that combines certain messages of related type, and visibleMessages is the filtered structure that removes messages that should not be rendered - itemContent={itemContent} - atBottomStateChange={(isAtBottom) => { - setIsAtBottom(isAtBottom) - if (isAtBottom) { - disableAutoScrollRef.current = false - } - setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom) - }} - atBottomThreshold={10} // anything lower causes issues with followOutput - initialTopMostItemIndex={groupedMessages.length - 1} - /> +
+ {groupedMessages.map((messageOrGroup, index) => { + // Reusing the old logic from itemContent callback directly here + // browser session group + if (Array.isArray(messageOrGroup)) { + return ( + // Optimization, applies CSS containment +
+ expandedRows[messageTs] ?? false} + onToggleExpand={(messageTs: number) => toggleRowExpansion(messageTs)} + /> +
+ ) + } + + // Regular message + return ( + // Optimization, applies CSS containment +
+ toggleRowExpansion(messageOrGroup.ts)} + lastModifiedMessage={modifiedMessages.at(-1)} + isLast={index === groupedMessages.length - 1} + onHeightChange={handleRowHeightChange} + isStreaming={isStreaming} + onSuggestionClick={handleSuggestionClick} // Use the memoized callback + /> +
+ ) + })} +
{showScrollToBottom ? ( @@ -1280,8 +1280,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie }}> { + // Ensure smooth scroll is called and reset disable flag scrollToBottomSmooth() disableAutoScrollRef.current = false + setShowScrollToBottom(false) // Hide button instantly }} title={t("chat:scrollToBottom")}> @@ -1372,6 +1374,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie onSelectImages={selectImages} shouldDisableImages={shouldDisableImages} onHeightChange={() => { + // Scrolling to bottom if currently at bottom when text area resizes if (isAtBottom) { scrollToBottomAuto() }