import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react" import { useDeepCompareEffect, useEvent } from "react-use" import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" import removeMd from "remove-markdown" import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import useSound from "use-sound" import { LRUCache } from "lru-cache" import { Trans } from "react-i18next" import { useDebounceEffect } from "@src/utils/useDebounceEffect" import { appendImages } from "@src/utils/imageUtils" import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting" import { batchConsecutive } from "@src/utils/batchConsecutive" import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType } from "@roo-code/types" import { isRetiredProvider } from "@roo-code/types" import { findLast } from "@roo/array" import { SuggestionItem } from "@roo-code/types" import { combineApiRequests } from "@roo/combineApiRequests" import { combineCommandSequences } from "@roo/combineCommandSequences" import { getApiMetrics } from "@roo/getApiMetrics" import { getAllModes } from "@roo/modes" import { ProfileValidator } from "@roo/ProfileValidator" import { getLatestTodo } from "@roo/todo" import { vscode } from "@src/utils/vscode" import { useAppTranslation } from "@src/i18n/TranslationContext" import { useExtensionState } from "@src/context/ExtensionStateContext" import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" import RooHero from "@src/components/welcome/RooHero" import RooTips from "@src/components/welcome/RooTips" import { StandardTooltip, Button } from "@src/components/ui" import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog" import TelemetryBanner from "../common/TelemetryBanner" import VersionIndicator from "../common/VersionIndicator" import HistoryPreview from "../history/HistoryPreview" import Announcement from "./Announcement" import ChatRow from "./ChatRow" import WarningRow from "./WarningRow" import { ChatTextArea } from "./ChatTextArea" import TaskHeader from "./TaskHeader" import ProfileViolationWarning from "./ProfileViolationWarning" import { CheckpointWarning } from "./CheckpointWarning" import { QueuedMessages } from "./QueuedMessages" import { WorktreeSelector } from "./WorktreeSelector" import FileChangesPanel from "./FileChangesPanel" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" import { useScrollLifecycle } from "@src/hooks/useScrollLifecycle" import { Cloud } from "lucide-react" export interface ChatViewProps { isHidden: boolean showAnnouncement: boolean hideAnnouncement: () => void } export interface ChatViewRef { acceptInput: () => void } export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit. const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0 const ChatViewComponent: React.ForwardRefRenderFunction = ( { isHidden, showAnnouncement, hideAnnouncement }, ref, ) => { const [audioBaseUri] = useState(() => { return (window as unknown as { AUDIO_BASE_URI?: string }).AUDIO_BASE_URI || "" }) const { t } = useAppTranslation() const modeShortcutText = `${isMac ? "⌘" : "Ctrl"} + . ${t("chat:forNextMode")}, ${isMac ? "⌘" : "Ctrl"} + Shift + . ${t("chat:forPreviousMode")}` const { clineMessages: messages, currentTaskItem, currentTaskTodos, taskHistory, apiConfiguration, organizationAllowList, mode, setMode, alwaysAllowModeSwitch, customModes, telemetrySetting, soundEnabled, soundVolume, cloudIsAuthenticated, messageQueue = [], showWorktreesInHomeScreen, chatFontSizeMultiplier, } = useExtensionState() // Show a WarningRow when the user sends a message with a retired provider. const [showRetiredProviderWarning, setShowRetiredProviderWarning] = useState(false) // When the provider changes, clear the retired-provider warning. const providerName = apiConfiguration?.apiProvider useEffect(() => { setShowRetiredProviderWarning(false) }, [providerName]) const messagesRef = useRef(messages) useEffect(() => { messagesRef.current = messages }, [messages]) // Leaving this less safe version here since if the first message is not a // task, then the extension is in a bad state and needs to be debugged (see // Cline.abort). const task = useMemo(() => messages.at(0), [messages]) const latestTodos = useMemo(() => { // First check if we have initial todos from the state (for new subtasks) if (currentTaskTodos && currentTaskTodos.length > 0) { // Check if there are any todo updates in messages const messageBasedTodos = getLatestTodo(messages) // If there are message-based todos, they take precedence (user has updated them) if (messageBasedTodos && messageBasedTodos.length > 0) { return messageBasedTodos } // Otherwise use the initial todos from state return currentTaskTodos } // Fall back to extracting from messages return getLatestTodo(messages) }, [messages, currentTaskTodos]) const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages]) // Has to be after api_req_finished are all reduced into api_req_started messages. const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages]) const [inputValue, setInputValue] = useState("") const inputValueRef = useRef(inputValue) const textAreaRef = useRef(null) const [sendingDisabled, setSendingDisabled] = useState(false) const [selectedImages, setSelectedImages] = useState([]) // We need to hold on to the ask because useEffect > lastMessage will always // let us know when an ask comes in and handle it, but by the time // handleMessage is called, the last message might not be the ask anymore // (it could be a say that followed). const [clineAsk, setClineAsk] = useState(undefined) const [enableButtons, setEnableButtons] = useState(false) const [primaryButtonText, setPrimaryButtonText] = useState(undefined) const [secondaryButtonText, setSecondaryButtonText] = useState(undefined) const [_didClickCancel, setDidClickCancel] = useState(false) const virtuosoRef = useRef(null) const [expandedRows, setExpandedRows] = useState>({}) const prevExpandedRowsRef = useRef>() const scrollContainerRef = useRef(null) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [checkpointWarning, setCheckpointWarning] = useState< { type: "WAIT_TIMEOUT" | "INIT_TIMEOUT"; timeout: number } | undefined >(undefined) const [isCondensing, setIsCondensing] = useState(false) const [showAnnouncementModal, setShowAnnouncementModal] = useState(false) const everVisibleMessagesTsRef = useRef>( new LRUCache({ max: 100, ttl: 1000 * 60 * 5, }), ) const autoApproveTimeoutRef = useRef(null) const userRespondedRef = useRef(false) const [currentFollowUpTs, setCurrentFollowUpTs] = useState(null) const [aggregatedCostsMap, setAggregatedCostsMap] = useState< Map< string, { totalCost: number ownCost: number childrenCost: number } > >(new Map()) const clineAskRef = useRef(clineAsk) useEffect(() => { clineAskRef.current = clineAsk }, [clineAsk]) const { isOpen: isUpsellOpen, openUpsell, closeUpsell, handleConnect, } = useCloudUpsell({ autoOpenOnAuth: false, }) // Keep inputValueRef in sync with inputValue state useEffect(() => { inputValueRef.current = inputValue }, [inputValue]) // Compute whether auto-approval is paused (user is typing in a followup) const isFollowUpAutoApprovalPaused = useMemo(() => { return !!(inputValue && inputValue.trim().length > 0 && clineAsk === "followup") }, [inputValue, clineAsk]) // Cancel auto-approval timeout when user starts typing useEffect(() => { // Only send cancel if there's actual input (user is typing) // and we have a pending follow-up question if (isFollowUpAutoApprovalPaused) { vscode.postMessage({ type: "cancelAutoApproval" }) } }, [isFollowUpAutoApprovalPaused]) const isProfileDisabled = useMemo( () => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList), [apiConfiguration, organizationAllowList], ) // UI layout depends on the last 2 messages (since it relies on the content // of these messages, we are deep comparing) i.e. the button state after // hitting button sets enableButtons to false, and this effect otherwise // would have to true again even if messages didn't change. const lastMessage = useMemo(() => messages.at(-1), [messages]) const secondLastMessage = useMemo(() => messages.at(-2), [messages]) const volume = typeof soundVolume === "number" ? soundVolume : 0.5 const [playNotification] = useSound(`${audioBaseUri}/notification.wav`, { volume, soundEnabled, interrupt: true }) const [playCelebration] = useSound(`${audioBaseUri}/celebration.wav`, { volume, soundEnabled, interrupt: true }) const [playProgressLoop] = useSound(`${audioBaseUri}/progress_loop.wav`, { volume, soundEnabled, interrupt: true }) const lastPlayedRef = useRef>({}) const playSound = useCallback( (audioType: AudioType) => { if (!soundEnabled) { return } const now = Date.now() const lastPlayed = lastPlayedRef.current[audioType] ?? 0 if (now - lastPlayed < 100) { return } // debounce: skip if played within 100ms lastPlayedRef.current[audioType] = now switch (audioType) { case "notification": playNotification() break case "celebration": playCelebration() break case "progress_loop": playProgressLoop() break default: console.warn(`Unknown audio type: ${audioType}`) } }, [soundEnabled, playNotification, playCelebration, playProgressLoop], ) function playTts(text: string) { vscode.postMessage({ type: "playTts", text }) } useDeepCompareEffect(() => { // if last message is an ask, show user ask UI // if user finished a task, then start a new task with a new conversation history since in this moment that the extension is waiting for user response, the user could close the extension and the conversation history would be lost. // basically as long as a task is active, the conversation history will be persisted if (lastMessage) { switch (lastMessage.type) { case "ask": // Reset user response flag when a new ask arrives to allow auto-approval userRespondedRef.current = false const isPartial = lastMessage.partial === true switch (lastMessage.ask) { case "api_req_failed": playSound("progress_loop") setSendingDisabled(true) setClineAsk("api_req_failed") setEnableButtons(true) setPrimaryButtonText(t("chat:retry.title")) setSecondaryButtonText(t("chat:startNewTask.title")) break case "mistake_limit_reached": playSound("progress_loop") setSendingDisabled(false) setClineAsk("mistake_limit_reached") setEnableButtons(true) setPrimaryButtonText(t("chat:proceedAnyways.title")) setSecondaryButtonText(t("chat:startNewTask.title")) break case "followup": setSendingDisabled(isPartial) setClineAsk("followup") // setting enable buttons to `false` would trigger a focus grab when // the text area is enabled which is undesirable. // We have no buttons for this tool, so no problem having them "enabled" // to workaround this issue. See #1358. setEnableButtons(true) setPrimaryButtonText(undefined) setSecondaryButtonText(undefined) break case "tool": setSendingDisabled(isPartial) setClineAsk("tool") setEnableButtons(!isPartial) const tool = JSON.parse(lastMessage.text || "{}") as ClineSayTool switch (tool.tool) { case "editedExistingFile": case "appliedDiff": case "newFileCreated": if (tool.batchDiffs && Array.isArray(tool.batchDiffs)) { setPrimaryButtonText(t("chat:edit-batch.approve.title")) setSecondaryButtonText(t("chat:edit-batch.deny.title")) } else { setPrimaryButtonText(t("chat:save.title")) setSecondaryButtonText(t("chat:reject.title")) } break case "generateImage": setPrimaryButtonText(t("chat:save.title")) setSecondaryButtonText(t("chat:reject.title")) break case "finishTask": setPrimaryButtonText(t("chat:completeSubtaskAndReturn")) setSecondaryButtonText(undefined) break case "readFile": if (tool.batchFiles && Array.isArray(tool.batchFiles)) { setPrimaryButtonText(t("chat:read-batch.approve.title")) setSecondaryButtonText(t("chat:read-batch.deny.title")) } else { setPrimaryButtonText(t("chat:approve.title")) setSecondaryButtonText(t("chat:reject.title")) } break case "listFilesTopLevel": case "listFilesRecursive": if (tool.batchDirs && Array.isArray(tool.batchDirs)) { setPrimaryButtonText(t("chat:list-batch.approve.title")) setSecondaryButtonText(t("chat:list-batch.deny.title")) } else { setPrimaryButtonText(t("chat:approve.title")) setSecondaryButtonText(t("chat:reject.title")) } break default: setPrimaryButtonText(t("chat:approve.title")) setSecondaryButtonText(t("chat:reject.title")) break } break case "command": setSendingDisabled(isPartial) setClineAsk("command") setEnableButtons(!isPartial) setPrimaryButtonText(t("chat:runCommand.title")) setSecondaryButtonText(t("chat:reject.title")) break case "command_output": setSendingDisabled(false) setClineAsk("command_output") setEnableButtons(true) setPrimaryButtonText(t("chat:proceedWhileRunning.title")) setSecondaryButtonText(t("chat:killCommand.title")) break case "use_mcp_server": setSendingDisabled(isPartial) setClineAsk("use_mcp_server") setEnableButtons(!isPartial) setPrimaryButtonText(t("chat:approve.title")) setSecondaryButtonText(t("chat:reject.title")) break case "completion_result": // Extension waiting for feedback, but we can just present a new task button. // Only play celebration sound if there are no queued messages. if (!isPartial && messageQueue.length === 0) { playSound("celebration") } setSendingDisabled(isPartial) setClineAsk("completion_result") setEnableButtons(!isPartial) setPrimaryButtonText(t("chat:startNewTask.title")) setSecondaryButtonText(undefined) break case "resume_task": setSendingDisabled(false) setClineAsk("resume_task") setEnableButtons(true) // For completed subtasks, show "Start New Task" instead of "Resume" // A subtask is considered completed if: // - It has a parentTaskId AND // - Its messages contain a completion_result (either ask or say) const isCompletedSubtask = currentTaskItem?.parentTaskId && messages.some( (msg) => msg.ask === "completion_result" || msg.say === "completion_result", ) if (isCompletedSubtask) { setPrimaryButtonText(t("chat:startNewTask.title")) setSecondaryButtonText(undefined) } else { setPrimaryButtonText(t("chat:resumeTask.title")) setSecondaryButtonText(t("chat:terminate.title")) } setDidClickCancel(false) // special case where we reset the cancel button state break case "resume_completed_task": setSendingDisabled(false) setClineAsk("resume_completed_task") setEnableButtons(true) setPrimaryButtonText(t("chat:startNewTask.title")) setSecondaryButtonText(undefined) setDidClickCancel(false) break } break case "say": // Don't want to reset since there could be a "say" after // an "ask" while ask is waiting for response. switch (lastMessage.say) { case "api_req_retry_delayed": case "api_req_rate_limit_wait": setSendingDisabled(true) break case "api_req_started": // Clear button state when a new API request starts // This fixes buttons persisting when the task continues setSendingDisabled(true) // Note: Do NOT clear selectedImages here. This handler fires // every time the backend starts an API call, which would wipe // images the user has pasted while the chat is in progress. // Images are already cleared in the appropriate user-action // handlers (handleSendMessage, handlePrimaryButtonClick, etc.). setClineAsk(undefined) setEnableButtons(false) setPrimaryButtonText(undefined) setSecondaryButtonText(undefined) break case "api_req_finished": case "error": case "text": case "command_output": case "mcp_server_request_started": case "mcp_server_response": case "completion_result": break } break } } }, [lastMessage, secondLastMessage]) // Update button text when messages change (e.g., completion_result is added) for subtasks in resume_task state useEffect(() => { if (clineAsk === "resume_task" && currentTaskItem?.parentTaskId) { const hasCompletionResult = messages.some( (msg) => msg.ask === "completion_result" || msg.say === "completion_result", ) if (hasCompletionResult) { setPrimaryButtonText(t("chat:startNewTask.title")) setSecondaryButtonText(undefined) } } }, [clineAsk, currentTaskItem?.parentTaskId, messages, t]) useEffect(() => { if (messages.length === 0) { setSendingDisabled(false) setClineAsk(undefined) setEnableButtons(false) setPrimaryButtonText(undefined) setSecondaryButtonText(undefined) } }, [messages.length]) // Reset UI states when task changes. Scroll lifecycle is handled by // useScrollLifecycle which has its own effect keyed on taskTs. useEffect(() => { setExpandedRows({}) everVisibleMessagesTsRef.current.clear() setCurrentFollowUpTs(null) setIsCondensing(false) if (autoApproveTimeoutRef.current) { clearTimeout(autoApproveTimeoutRef.current) autoApproveTimeoutRef.current = null } userRespondedRef.current = false }, [task?.ts]) const taskTs = task?.ts // Request aggregated costs when task changes and has childIds useEffect(() => { if (taskTs && currentTaskItem?.childIds && currentTaskItem.childIds.length > 0) { vscode.postMessage({ type: "getTaskWithAggregatedCosts", text: currentTaskItem.id, }) } }, [taskTs, currentTaskItem?.id, currentTaskItem?.childIds]) useEffect(() => { if (isHidden) { everVisibleMessagesTsRef.current.clear() } }, [isHidden]) useEffect(() => { const cache = everVisibleMessagesTsRef.current return () => { cache.clear() } }, []) const isStreaming = useMemo(() => { // Checking clineAsk isn't enough since messages effect may be called // again for a tool for example, set clineAsk to its value, and if the // next message is not an ask then it doesn't reset. This is likely due // to how much more often we're updating messages as compared to before, // and should be resolved with optimizations as it's likely a rendering // bug. But as a final guard for now, the cancel button will show if the // last message is not an ask. const isLastAsk = !!modifiedMessages.at(-1)?.ask const isToolCurrentlyAsking = isLastAsk && clineAsk !== undefined && enableButtons && primaryButtonText !== undefined if (isToolCurrentlyAsking) { return false } const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true if (isLastMessagePartial) { return true } else { const lastApiReqStarted = findLast( modifiedMessages, (message: ClineMessage) => message.say === "api_req_started", ) if ( lastApiReqStarted && lastApiReqStarted.text !== null && lastApiReqStarted.text !== undefined && lastApiReqStarted.say === "api_req_started" ) { const cost = JSON.parse(lastApiReqStarted.text).cost if (cost === undefined) { return true // API request has not finished yet. } } } return false }, [modifiedMessages, clineAsk, enableButtons, primaryButtonText]) const markFollowUpAsAnswered = useCallback(() => { const lastFollowUpMessage = messagesRef.current.findLast((msg: ClineMessage) => msg.ask === "followup") if (lastFollowUpMessage) { setCurrentFollowUpTs(lastFollowUpMessage.ts) } }, []) const handleChatReset = useCallback(() => { // Clear any pending auto-approval timeout if (autoApproveTimeoutRef.current) { clearTimeout(autoApproveTimeoutRef.current) autoApproveTimeoutRef.current = null } // Reset user response flag for new message userRespondedRef.current = false // Only reset message-specific state, preserving mode. setInputValue("") setSendingDisabled(true) setSelectedImages([]) setClineAsk(undefined) setEnableButtons(false) // Do not reset mode here as it should persist. // setPrimaryButtonText(undefined) // setSecondaryButtonText(undefined) }, []) /** * Handles sending messages to the extension * @param text - The message text to send * @param images - Array of image data URLs to send with the message */ const handleSendMessage = useCallback( (text: string, images: string[]) => { text = text.trim() if (text || images.length > 0) { // Intercept when the active provider is retired — show a // WarningRow instead of sending anything to the backend. if (apiConfiguration?.apiProvider && isRetiredProvider(apiConfiguration.apiProvider)) { setShowRetiredProviderWarning(true) return } // Queue message if: // - Task is busy (sendingDisabled) // - API request in progress (isStreaming) // - Queue has items (preserve message order during drain) // - Command is running (command_output) - user's message should be queued for AI, not sent to terminal if ( sendingDisabled || isStreaming || messageQueue.length > 0 || clineAskRef.current === "command_output" ) { try { console.log("queueMessage", text, images) vscode.postMessage({ type: "queueMessage", text, images }) setInputValue("") setSelectedImages([]) } catch (error) { console.error( `Failed to queue message: ${error instanceof Error ? error.message : String(error)}`, ) } return } // Mark that user has responded - this prevents any pending auto-approvals. userRespondedRef.current = true if (messagesRef.current.length === 0) { vscode.postMessage({ type: "newTask", text, images }) } else if (clineAskRef.current) { if (clineAskRef.current === "followup") { markFollowUpAsAnswered() } // Use clineAskRef.current switch ( clineAskRef.current // Use clineAskRef.current ) { case "followup": case "tool": case "command": // User can provide feedback to a tool or command use. case "use_mcp_server": case "completion_result": // If this happens then the user has feedback for the completion result. case "resume_task": case "resume_completed_task": case "mistake_limit_reached": vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images, }) break // There is no other case that a textfield should be enabled. } } else { // This is a new message in an ongoing task. vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images }) } handleChatReset() } }, [ handleChatReset, markFollowUpAsAnswered, sendingDisabled, isStreaming, messageQueue.length, apiConfiguration?.apiProvider, ], // messagesRef and clineAskRef are stable ) const handleSetChatBoxMessage = useCallback( (text: string, images: string[]) => { // Avoid nested template literals by breaking down the logic let newValue = text if (inputValue !== "") { newValue = inputValue + " " + text } setInputValue(newValue) setSelectedImages([...selectedImages, ...images]) }, [inputValue, selectedImages], ) const startNewTask = useCallback(() => { setShowRetiredProviderWarning(false) vscode.postMessage({ type: "clearTask" }) }, []) // Handle stop button click from textarea const handleStopTask = useCallback(() => { vscode.postMessage({ type: "cancelTask" }) setDidClickCancel(true) }, [setDidClickCancel]) // Handle enqueue button click from textarea const handleEnqueueCurrentMessage = useCallback(() => { const text = inputValue.trim() if (text || selectedImages.length > 0) { vscode.postMessage({ type: "queueMessage", text, images: selectedImages, }) setInputValue("") setSelectedImages([]) } }, [inputValue, selectedImages]) // This logic depends on the useEffect[messages] above to set clineAsk, // after which buttons are shown and we then send an askResponse to the // extension. const handlePrimaryButtonClick = useCallback( (text?: string, images?: string[]) => { // Mark that user has responded userRespondedRef.current = true const trimmedInput = text?.trim() switch (clineAsk) { case "api_req_failed": case "command": case "tool": case "use_mcp_server": case "mistake_limit_reached": // Only send text/images if they exist if (trimmedInput || (images && images.length > 0)) { vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked", text: trimmedInput, images: images, }) // Clear input state after sending setInputValue("") setSelectedImages([]) } else { vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" }) } break case "resume_task": // For completed subtasks (tasks with a parentTaskId and a completion_result), // start a new task instead of resuming since the subtask is done const isCompletedSubtaskForClick = currentTaskItem?.parentTaskId && messagesRef.current.some( (msg) => msg.ask === "completion_result" || msg.say === "completion_result", ) if (isCompletedSubtaskForClick) { startNewTask() } else { // Only send text/images if they exist if (trimmedInput || (images && images.length > 0)) { vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked", text: trimmedInput, images: images, }) // Clear input state after sending setInputValue("") setSelectedImages([]) } else { vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" }) } } break case "completion_result": case "resume_completed_task": // Waiting for feedback, but we can just present a new task button startNewTask() break case "command_output": vscode.postMessage({ type: "terminalOperation", terminalOperation: "continue" }) break } setSendingDisabled(true) setClineAsk(undefined) setEnableButtons(false) setPrimaryButtonText(undefined) setSecondaryButtonText(undefined) }, [clineAsk, startNewTask, currentTaskItem?.parentTaskId], ) const handleSecondaryButtonClick = useCallback( (text?: string, images?: string[]) => { // Mark that user has responded userRespondedRef.current = true const trimmedInput = text?.trim() if (isStreaming) { vscode.postMessage({ type: "cancelTask" }) setDidClickCancel(true) return } switch (clineAsk) { case "api_req_failed": case "mistake_limit_reached": case "resume_task": startNewTask() break case "command": case "tool": case "use_mcp_server": // Only send text/images if they exist if (trimmedInput || (images && images.length > 0)) { vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked", text: trimmedInput, images: images, }) // Clear input state after sending setInputValue("") setSelectedImages([]) } else { // Responds to the API with a "This operation failed" and lets it try again vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" }) } break case "command_output": vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" }) break } setSendingDisabled(true) setClineAsk(undefined) setEnableButtons(false) }, [clineAsk, startNewTask, isStreaming, setDidClickCancel], ) const { info: model } = useSelectedModel(apiConfiguration) const selectImages = useCallback(() => vscode.postMessage({ type: "selectImages" }), []) const shouldDisableImages = !model?.supportsImages || selectedImages.length >= MAX_IMAGES_PER_MESSAGE const handleMessage = useCallback( (e: MessageEvent) => { const message: ExtensionMessage = e.data switch (message.type) { case "action": switch (message.action!) { case "didBecomeVisible": if (!isHidden && !sendingDisabled && !enableButtons) { textAreaRef.current?.focus() } break case "focusInput": textAreaRef.current?.focus() break } break case "selectedImages": // Only handle selectedImages if it's not for editing context // When context is "edit", ChatRow will handle the images if (message.context !== "edit") { setSelectedImages((prevImages: string[]) => appendImages(prevImages, message.images, MAX_IMAGES_PER_MESSAGE), ) } break case "invoke": switch (message.invoke!) { case "newChat": handleChatReset() break case "sendMessage": handleSendMessage(message.text ?? "", message.images ?? []) break case "setChatBoxMessage": handleSetChatBoxMessage(message.text ?? "", message.images ?? []) break case "primaryButtonClick": handlePrimaryButtonClick(message.text ?? "", message.images ?? []) break case "secondaryButtonClick": handleSecondaryButtonClick(message.text ?? "", message.images ?? []) break } break case "condenseTaskContextStarted": // Handle both manual and automatic condensation start // We don't check the task ID because: // 1. There can only be one active task at a time // 2. Task switching resets isCondensing to false (see useEffect with task?.ts dependency) // 3. For new tasks, currentTaskItem may not be populated yet due to async state updates if (message.text) { setIsCondensing(true) // Note: sendingDisabled is only set for manual condensation via handleCondenseContext // Automatic condensation doesn't disable sending since the task is already running } break case "condenseTaskContextResponse": // Same reasoning as above - we trust this is for the current task if (message.text) { if (isCondensing && sendingDisabled) { setSendingDisabled(false) } setIsCondensing(false) } break case "checkpointInitWarning": setCheckpointWarning(message.checkpointWarning) break case "interactionRequired": playSound("notification") break case "taskWithAggregatedCosts": if (message.text && message.aggregatedCosts) { setAggregatedCostsMap((prev) => { const newMap = new Map(prev) newMap.set(message.text!, message.aggregatedCosts!) return newMap }) } break } // textAreaRef.current is not explicitly required here since React // guarantees that ref will be stable across re-renders, and we're // not using its value but its reference. }, [ isCondensing, isHidden, sendingDisabled, enableButtons, handleChatReset, handleSendMessage, handleSetChatBoxMessage, handlePrimaryButtonClick, handleSecondaryButtonClick, setCheckpointWarning, playSound, ], ) useEvent("message", handleMessage) const visibleMessages = useMemo(() => { // Pre-compute checkpoint hashes that have associated user messages for O(1) lookup const userMessageCheckpointHashes = new Set() modifiedMessages.forEach((msg) => { if ( msg.say === "user_feedback" && msg.checkpoint && msg.checkpoint["type"] === "user_message" && msg.checkpoint["hash"] ) { userMessageCheckpointHashes.add(msg.checkpoint["hash"] as string) } }) // Remove the 500-message limit to prevent array index shifting // Virtuoso is designed to efficiently handle large lists through virtualization const newVisibleMessages = modifiedMessages.filter((message) => { // Filter out checkpoint_saved messages that should be suppressed if (message.say === "checkpoint_saved") { // Check if this checkpoint has the suppressMessage flag set if ( message.checkpoint && typeof message.checkpoint === "object" && "suppressMessage" in message.checkpoint && message.checkpoint.suppressMessage ) { return false } // Also filter out checkpoint messages associated with user messages (legacy behavior) if (message.text && userMessageCheckpointHashes.has(message.text)) { return false } } if (everVisibleMessagesTsRef.current.has(message.ts)) { const alwaysHiddenOnceProcessedAsk: ClineAsk[] = [ "api_req_failed", "resume_task", "resume_completed_task", ] const alwaysHiddenOnceProcessedSay = [ "api_req_finished", "api_req_retried", "api_req_deleted", "mcp_server_request_started", ] if (message.ask && alwaysHiddenOnceProcessedAsk.includes(message.ask)) return false if (message.say && alwaysHiddenOnceProcessedSay.includes(message.say)) return false if (message.say === "text" && (message.text ?? "") === "" && (message.images?.length ?? 0) === 0) { return false } return true } switch (message.ask) { case "completion_result": if (message.text === "") return false break case "api_req_failed": case "resume_task": case "resume_completed_task": return false } switch (message.say) { case "api_req_finished": case "api_req_retried": case "api_req_deleted": return false case "api_req_retry_delayed": case "api_req_rate_limit_wait": const last1 = modifiedMessages.at(-1) const last2 = modifiedMessages.at(-2) if (last1?.ask === "resume_task" && last2 === message) { return true } else if (message !== last1) { return false } break case "text": if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) return false break case "mcp_server_request_started": return false } return true }) const viewportStart = Math.max(0, newVisibleMessages.length - 100) newVisibleMessages .slice(viewportStart) .forEach((msg: ClineMessage) => everVisibleMessagesTsRef.current.set(msg.ts, true)) return newVisibleMessages }, [modifiedMessages]) useEffect(() => { const cleanupInterval = setInterval(() => { const cache = everVisibleMessagesTsRef.current const currentMessageIds = new Set(modifiedMessages.map((m: ClineMessage) => m.ts)) const viewportMessages = visibleMessages.slice(Math.max(0, visibleMessages.length - 100)) const viewportMessageIds = new Set(viewportMessages.map((m: ClineMessage) => m.ts)) cache.forEach((_value: boolean, key: number) => { if (!currentMessageIds.has(key) && !viewportMessageIds.has(key)) { cache.delete(key) } }) }, 60000) return () => clearInterval(cleanupInterval) }, [modifiedMessages, visibleMessages]) useDebounceEffect( () => { if (!isHidden && !sendingDisabled && !enableButtons) { textAreaRef.current?.focus() } }, 50, [isHidden, sendingDisabled, enableButtons], ) useEffect(() => { // This ensures the first message is not read, future user messages are // labeled as `user_feedback`. if (lastMessage && messages.length > 1) { if ( typeof lastMessage.text === "string" && // has text (must be string for startsWith) (lastMessage.say === "text" || lastMessage.say === "completion_result") && // is a text message !lastMessage.partial && // not a partial message !lastMessage.text.startsWith("{") // not a json object ) { let text = lastMessage?.text || "" const mermaidRegex = /```mermaid[\s\S]*?```/g // remove mermaid diagrams from text text = text.replace(mermaidRegex, "") // remove markdown from text text = removeMd(text) // ensure message is not a duplicate of last read message if (text !== lastTtsRef.current) { try { playTts(text) lastTtsRef.current = text } catch (error) { console.error("Failed to execute text-to-speech:", error) } } } } // Update previous value. setWasStreaming(isStreaming) }, [isStreaming, lastMessage, wasStreaming, messages.length]) const groupedMessages = useMemo(() => { const filtered: ClineMessage[] = visibleMessages // Helper to check if a message is a read_file ask that should be batched const isReadFileAsk = (msg: ClineMessage): boolean => { if (msg.type !== "ask" || msg.ask !== "tool") return false try { const tool = JSON.parse(msg.text || "{}") return tool.tool === "readFile" && !tool.batchFiles // Don't re-batch already batched } catch { return false } } // Helper to check if a message is a list_files ask that should be batched const isListFilesAsk = (msg: ClineMessage): boolean => { if (msg.type !== "ask" || msg.ask !== "tool") return false try { const tool = JSON.parse(msg.text || "{}") return ( (tool.tool === "listFilesTopLevel" || tool.tool === "listFilesRecursive") && !tool.batchDirs // Don't re-batch already batched ) } catch { return false } } // Set of tool names that represent file-editing operations const editFileTools = new Set([ "editedExistingFile", "appliedDiff", "newFileCreated", "insertContent", "searchAndReplace", ]) // Helper to check if a message is a file-edit ask that should be batched const isEditFileAsk = (msg: ClineMessage): boolean => { if (msg.type !== "ask" || msg.ask !== "tool") return false try { const tool = JSON.parse(msg.text || "{}") return editFileTools.has(tool.tool) && !tool.batchDiffs // Don't re-batch already batched } catch { return false } } // Synthesize a batch of consecutive read_file asks into a single message const synthesizeReadFileBatch = (batch: ClineMessage[]): ClineMessage => { const batchFiles = batch.map((batchMsg) => { try { const tool = JSON.parse(batchMsg.text || "{}") return { path: tool.path || "", lineSnippet: tool.reason || "", isOutsideWorkspace: tool.isOutsideWorkspace || false, key: `${tool.path}${tool.reason ? ` (${tool.reason})` : ""}`, content: tool.content || "", } } catch { return { path: "", lineSnippet: "", key: "", content: "" } } }) let firstTool try { firstTool = JSON.parse(batch[0].text || "{}") } catch { return batch[0] } return { ...batch[0], text: JSON.stringify({ ...firstTool, batchFiles }), } } // Synthesize a batch of consecutive list_files asks into a single message const synthesizeListFilesBatch = (batch: ClineMessage[]): ClineMessage => { const batchDirs = batch.map((batchMsg) => { try { const tool = JSON.parse(batchMsg.text || "{}") return { path: tool.path || "", recursive: tool.tool === "listFilesRecursive", isOutsideWorkspace: tool.isOutsideWorkspace || false, key: tool.path || "", } } catch { return { path: "", recursive: false, key: "" } } }) let firstTool try { firstTool = JSON.parse(batch[0].text || "{}") } catch { return batch[0] } return { ...batch[0], text: JSON.stringify({ ...firstTool, batchDirs }), } } // Synthesize a batch of consecutive file-edit asks into a single message const synthesizeEditFileBatch = (batch: ClineMessage[]): ClineMessage => { const batchDiffs = batch.map((batchMsg) => { try { const tool = JSON.parse(batchMsg.text || "{}") return { path: tool.path || "", changeCount: 1, key: tool.path || "", content: tool.content || tool.diff || "", diffStats: tool.diffStats, } } catch { return { path: "", changeCount: 0, key: "", content: "" } } }) let firstTool try { firstTool = JSON.parse(batch[0].text || "{}") } catch { return batch[0] } return { ...batch[0], text: JSON.stringify({ ...firstTool, batchDiffs }), } } // Consolidate consecutive ask messages into batches const readFileBatched = batchConsecutive(filtered, isReadFileAsk, synthesizeReadFileBatch) const listFilesBatched = batchConsecutive(readFileBatched, isListFilesAsk, synthesizeListFilesBatch) const result = batchConsecutive(listFilesBatched, isEditFileAsk, synthesizeEditFileBatch) if (isCondensing) { result.push({ type: "say", say: "condense_context", ts: Date.now(), partial: true, } as ClineMessage) } return result }, [isCondensing, visibleMessages]) // Scroll lifecycle is managed by a dedicated hook to keep ChatView focused // on message handling and UI orchestration. const { showScrollToBottom, handleRowHeightChange, handleScrollToBottomClick, enterUserBrowsingHistory, followOutputCallback, atBottomStateChangeCallback, scrollToBottomAuto, isAtBottomRef, scrollPhaseRef, } = useScrollLifecycle({ virtuosoRef, scrollContainerRef, taskTs: task?.ts, isStreaming, isHidden, hasTask: !!task, }) // Expanding a row indicates the user is browsing; disable sticky follow. // Placed after the hook call so enterUserBrowsingHistory is defined. useEffect(() => { const prev = prevExpandedRowsRef.current let wasAnyRowExpandedByUser = false if (prev) { for (const [tsKey, isExpanded] of Object.entries(expandedRows)) { const ts = Number(tsKey) if (isExpanded && !(prev[ts] ?? false)) { wasAnyRowExpandedByUser = true break } } } if (wasAnyRowExpandedByUser) { enterUserBrowsingHistory("row-expansion") } prevExpandedRowsRef.current = expandedRows }, [enterUserBrowsingHistory, expandedRows]) const handleSetExpandedRow = useCallback( (ts: number, expand?: boolean) => { setExpandedRows((prev: Record) => ({ ...prev, [ts]: expand === undefined ? !prev[ts] : expand, })) }, [setExpandedRows], // setExpandedRows is stable ) // Scroll when user toggles certain rows. const toggleRowExpansion = useCallback( (ts: number) => { handleSetExpandedRow(ts) // The logic to set disableAutoScrollRef.current = true on expansion // is now handled by the useEffect hook that observes expandedRows. }, [handleSetExpandedRow], ) // Effect to clear checkpoint warning when messages appear or task changes useEffect(() => { if (isHidden || !task) { setCheckpointWarning(undefined) } }, [modifiedMessages.length, isStreaming, isHidden, task]) const placeholderText = task ? t("chat:typeMessage") : t("chat:typeTask") const switchToMode = useCallback( (modeSlug: string): void => { // Update local state and notify extension to sync mode change. setMode(modeSlug) // Send the mode switch message. vscode.postMessage({ type: "mode", text: modeSlug }) }, [setMode], ) const handleSuggestionClickInRow = useCallback( (suggestion: SuggestionItem, event?: React.MouseEvent) => { // Mark that user has responded if this is a manual click (not auto-approval) if (event) { userRespondedRef.current = true } // Mark the current follow-up question as answered when a suggestion is clicked if (clineAsk === "followup" && !event?.shiftKey) { markFollowUpAsAnswered() } // Check if we need to switch modes if (suggestion.mode) { // Only switch modes if it's a manual click (event exists) or auto-approval is allowed const isManualClick = !!event if (isManualClick || alwaysAllowModeSwitch) { // Switch mode without waiting switchToMode(suggestion.mode) } } if (event?.shiftKey) { // Always append to existing text, don't overwrite setInputValue((currentValue: string) => { return currentValue !== "" ? `${currentValue} \n${suggestion.answer}` : suggestion.answer }) } else { // Don't clear the input value when sending a follow-up choice // The message should be sent but the text area should preserve what the user typed const preservedInput = inputValueRef.current handleSendMessage(suggestion.answer, []) // Restore the input value after sending setInputValue(preservedInput) } }, [handleSendMessage, setInputValue, switchToMode, alwaysAllowModeSwitch, clineAsk, markFollowUpAsAnswered], ) const handleBatchFileResponse = useCallback((response: { [key: string]: boolean }) => { // Handle batch file response, e.g., for file uploads vscode.postMessage({ type: "askResponse", askResponse: "objectResponse", text: JSON.stringify(response) }) }, []) // Cancel backend auto-approval timeout when FollowUpSuggest's countdown effect cleans up. // This is called when auto-approve is toggled off, a suggestion is clicked, or the component unmounts. const handleFollowUpUnmount = useCallback(() => { vscode.postMessage({ type: "cancelAutoApproval" }) }, []) const itemContent = useCallback( (index: number, messageOrGroup: ClineMessage) => { const hasCheckpoint = modifiedMessages.some((message) => message.say === "checkpoint_saved") // regular message return ( { let tool: any = {} try { tool = JSON.parse(messageOrGroup.text || "{}") } catch (_) { if (messageOrGroup.text?.includes("updateTodoList")) { tool = { tool: "updateTodoList" } } } return tool.tool === "updateTodoList" && enableButtons && !!primaryButtonText })() } hasCheckpoint={hasCheckpoint} /> ) }, [ expandedRows, toggleRowExpansion, modifiedMessages, groupedMessages.length, handleRowHeightChange, isStreaming, handleSuggestionClickInRow, handleBatchFileResponse, handleFollowUpUnmount, currentFollowUpTs, isFollowUpAutoApprovalPaused, enableButtons, primaryButtonText, ], ) // Function to handle mode switching const switchToNextMode = useCallback(() => { const allModes = getAllModes(customModes) const currentModeIndex = allModes.findIndex((m) => m.slug === mode) const nextModeIndex = (currentModeIndex + 1) % allModes.length // Update local state and notify extension to sync mode change switchToMode(allModes[nextModeIndex].slug) }, [mode, customModes, switchToMode]) // Function to handle switching to previous mode const switchToPreviousMode = useCallback(() => { const allModes = getAllModes(customModes) const currentModeIndex = allModes.findIndex((m) => m.slug === mode) const previousModeIndex = (currentModeIndex - 1 + allModes.length) % allModes.length // Update local state and notify extension to sync mode change switchToMode(allModes[previousModeIndex].slug) }, [mode, customModes, switchToMode]) // Mode switching keyboard handler. Scroll-intent keyboard detection // (PageUp, Home, ArrowUp) is handled by useScrollLifecycle. const handleKeyDown = useCallback( (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === ".") { event.preventDefault() if (event.shiftKey) { switchToPreviousMode() } else { switchToNextMode() } } }, [switchToNextMode, switchToPreviousMode], ) useEffect(() => { window.addEventListener("keydown", handleKeyDown) return () => { window.removeEventListener("keydown", handleKeyDown) } }, [handleKeyDown]) useImperativeHandle(ref, () => ({ acceptInput: () => { const hasInput = inputValue.trim() || selectedImages.length > 0 // Special case: during command_output, queue the message instead of // triggering the primary button action (which would lose the message) if (clineAskRef.current === "command_output" && hasInput) { vscode.postMessage({ type: "queueMessage", text: inputValue.trim(), images: selectedImages }) setInputValue("") setSelectedImages([]) return } if (enableButtons && primaryButtonText) { handlePrimaryButtonClick(inputValue, selectedImages) } else if (!sendingDisabled && !isProfileDisabled && hasInput) { handleSendMessage(inputValue, selectedImages) } }, })) const handleCondenseContext = (taskId: string) => { if (isCondensing || sendingDisabled) { return } setIsCondensing(true) setSendingDisabled(true) vscode.postMessage({ type: "condenseTaskContextRequest", text: taskId }) } const areButtonsVisible = showScrollToBottom || primaryButtonText || secondaryButtonText return (
{telemetrySetting === "unset" && } {(showAnnouncement || showAnnouncementModal) && ( { if (showAnnouncementModal) { setShowAnnouncementModal(false) } if (showAnnouncement) { hideAnnouncement() } }} /> )} {task ? ( <> 0 ) } parentTaskId={currentTaskItem?.parentTaskId} costBreakdown={ currentTaskItem?.id && aggregatedCostsMap.has(currentTaskItem.id) ? getCostBreakdownIfNeeded(aggregatedCostsMap.get(currentTaskItem.id)!, { own: t("common:costs.own"), subtasks: t("common:costs.subtasks"), }) : undefined } contextTokens={apiMetrics.contextTokens} buttonsDisabled={sendingDisabled} handleCondenseContext={handleCondenseContext} todos={latestTodos} /> {checkpointWarning && (
)} ) : (
setShowAnnouncementModal(true)} className="absolute top-2 right-3 z-10" />
{/* Show RooTips when authenticated or when user is new */} {taskHistory.length < 6 && } {/* Everyone should see their task history if any */} {taskHistory.length > 0 && }
{/* Logged out users should see a one-time upsell, but not for brand new users */} {!cloudIsAuthenticated && taskHistory.length >= 6 && ( } onClick={() => openUpsell()} dismissOnClick={false} className="bg-none mt-6 border-border rounded-xl p-3 !text-base"> , }} /> )}
)} {!task && showWorktreesInHomeScreen && } {task && ( <>
{areButtonsVisible && (
{showScrollToBottom ? ( ) : ( <> {primaryButtonText && ( )} {secondaryButtonText && ( )} )}
)} )} { if (messageQueue[index]) { vscode.postMessage({ type: "removeQueuedMessage", text: messageQueue[index].id }) } }} onUpdate={(index, newText) => { if (messageQueue[index]) { vscode.postMessage({ type: "editQueuedMessage", payload: { id: messageQueue[index].id, text: newText, images: messageQueue[index].images }, }) } }} /> {showRetiredProviderWarning && (
vscode.postMessage({ type: "switchTab", tab: "settings" })} />
)} handleSendMessage(inputValue, selectedImages)} onSelectImages={selectImages} shouldDisableImages={shouldDisableImages} onHeightChange={() => { if (isAtBottomRef.current && scrollPhaseRef.current !== "USER_BROWSING_HISTORY") { scrollToBottomAuto() } }} mode={mode} setMode={setMode} modeShortcutText={modeShortcutText} isStreaming={isStreaming} onStop={handleStopTask} onEnqueueMessage={handleEnqueueCurrentMessage} /> {isProfileDisabled && (
)}
) } const ChatView = forwardRef(ChatViewComponent) export default ChatView