ChatView.tsx 62 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014
  1. import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
  2. import { useDeepCompareEffect, useEvent, useMount } from "react-use"
  3. import debounce from "debounce"
  4. import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
  5. import removeMd from "remove-markdown"
  6. import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
  7. import useSound from "use-sound"
  8. import { LRUCache } from "lru-cache"
  9. import { useDebounceEffect } from "@src/utils/useDebounceEffect"
  10. import { appendImages } from "@src/utils/imageUtils"
  11. import type { ClineAsk, ClineMessage } from "@roo-code/types"
  12. import { ClineSayBrowserAction, ClineSayTool, ExtensionMessage } from "@roo/ExtensionMessage"
  13. import { McpServer, McpTool } from "@roo/mcp"
  14. import { findLast } from "@roo/array"
  15. import { FollowUpData, SuggestionItem } from "@roo-code/types"
  16. import { combineApiRequests } from "@roo/combineApiRequests"
  17. import { combineCommandSequences } from "@roo/combineCommandSequences"
  18. import { getApiMetrics } from "@roo/getApiMetrics"
  19. import { AudioType } from "@roo/WebviewMessage"
  20. import { getAllModes } from "@roo/modes"
  21. import { ProfileValidator } from "@roo/ProfileValidator"
  22. import { vscode } from "@src/utils/vscode"
  23. import {
  24. getCommandDecision,
  25. CommandDecision,
  26. findLongestPrefixMatch,
  27. parseCommand,
  28. } from "@src/utils/command-validation"
  29. import { useTranslation } from "react-i18next"
  30. import { useAppTranslation } from "@src/i18n/TranslationContext"
  31. import { useExtensionState } from "@src/context/ExtensionStateContext"
  32. import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel"
  33. import RooHero from "@src/components/welcome/RooHero"
  34. import RooTips from "@src/components/welcome/RooTips"
  35. import RooCloudCTA from "@src/components/welcome/RooCloudCTA"
  36. import { StandardTooltip } from "@src/components/ui"
  37. import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState"
  38. import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles"
  39. import TelemetryBanner from "../common/TelemetryBanner"
  40. import VersionIndicator from "../common/VersionIndicator"
  41. import { useTaskSearch } from "../history/useTaskSearch"
  42. import HistoryPreview from "../history/HistoryPreview"
  43. import Announcement from "./Announcement"
  44. import BrowserSessionRow from "./BrowserSessionRow"
  45. import ChatRow from "./ChatRow"
  46. import ChatTextArea from "./ChatTextArea"
  47. import TaskHeader from "./TaskHeader"
  48. import AutoApproveMenu from "./AutoApproveMenu"
  49. import SystemPromptWarning from "./SystemPromptWarning"
  50. import ProfileViolationWarning from "./ProfileViolationWarning"
  51. import { CheckpointWarning } from "./CheckpointWarning"
  52. import QueuedMessages from "./QueuedMessages"
  53. import { getLatestTodo } from "@roo/todo"
  54. import { QueuedMessage } from "@roo-code/types"
  55. export interface ChatViewProps {
  56. isHidden: boolean
  57. showAnnouncement: boolean
  58. hideAnnouncement: () => void
  59. }
  60. export interface ChatViewRef {
  61. acceptInput: () => void
  62. }
  63. export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
  64. const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
  65. const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewProps> = (
  66. { isHidden, showAnnouncement, hideAnnouncement },
  67. ref,
  68. ) => {
  69. const isMountedRef = useRef(true)
  70. const [audioBaseUri] = useState(() => {
  71. const w = window as any
  72. return w.AUDIO_BASE_URI || ""
  73. })
  74. const { t } = useAppTranslation()
  75. const { t: tSettings } = useTranslation("settings")
  76. const modeShortcutText = `${isMac ? "⌘" : "Ctrl"} + . ${t("chat:forNextMode")}, ${isMac ? "⌘" : "Ctrl"} + Shift + . ${t("chat:forPreviousMode")}`
  77. const {
  78. clineMessages: messages,
  79. currentTaskItem,
  80. taskHistory,
  81. apiConfiguration,
  82. organizationAllowList,
  83. mcpServers,
  84. alwaysAllowBrowser,
  85. alwaysAllowReadOnly,
  86. alwaysAllowReadOnlyOutsideWorkspace,
  87. alwaysAllowWrite,
  88. alwaysAllowWriteOutsideWorkspace,
  89. alwaysAllowWriteProtected,
  90. alwaysAllowExecute,
  91. alwaysAllowMcp,
  92. allowedCommands,
  93. deniedCommands,
  94. writeDelayMs,
  95. followupAutoApproveTimeoutMs,
  96. mode,
  97. setMode,
  98. autoApprovalEnabled,
  99. alwaysAllowModeSwitch,
  100. alwaysAllowSubtasks,
  101. alwaysAllowFollowupQuestions,
  102. alwaysAllowUpdateTodoList,
  103. customModes,
  104. telemetrySetting,
  105. hasSystemPromptOverride,
  106. historyPreviewCollapsed, // Added historyPreviewCollapsed
  107. soundEnabled,
  108. soundVolume,
  109. cloudIsAuthenticated,
  110. } = useExtensionState()
  111. const messagesRef = useRef(messages)
  112. useEffect(() => {
  113. messagesRef.current = messages
  114. }, [messages])
  115. const { tasks } = useTaskSearch()
  116. // Initialize expanded state based on the persisted setting (default to expanded if undefined)
  117. const [isExpanded, setIsExpanded] = useState(
  118. historyPreviewCollapsed === undefined ? true : !historyPreviewCollapsed,
  119. )
  120. const toggleExpanded = useCallback(() => {
  121. const newState = !isExpanded
  122. setIsExpanded(newState)
  123. // Send message to extension to persist the new collapsed state
  124. vscode.postMessage({ type: "setHistoryPreviewCollapsed", bool: !newState })
  125. }, [isExpanded])
  126. // Leaving this less safe version here since if the first message is not a
  127. // task, then the extension is in a bad state and needs to be debugged (see
  128. // Cline.abort).
  129. const task = useMemo(() => messages.at(0), [messages])
  130. const latestTodos = useMemo(() => {
  131. return getLatestTodo(messages)
  132. }, [messages])
  133. const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
  134. // Has to be after api_req_finished are all reduced into api_req_started messages.
  135. const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
  136. const [inputValue, setInputValue] = useState("")
  137. const textAreaRef = useRef<HTMLTextAreaElement>(null)
  138. const [sendingDisabled, setSendingDisabled] = useState(false)
  139. const [selectedImages, setSelectedImages] = useState<string[]>([])
  140. const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([])
  141. const isProcessingQueueRef = useRef(false)
  142. const retryCountRef = useRef<Map<string, number>>(new Map())
  143. const MAX_RETRY_ATTEMPTS = 3
  144. // 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)
  145. const [clineAsk, setClineAsk] = useState<ClineAsk | undefined>(undefined)
  146. const [enableButtons, setEnableButtons] = useState<boolean>(false)
  147. const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
  148. const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
  149. const [didClickCancel, setDidClickCancel] = useState(false)
  150. const virtuosoRef = useRef<VirtuosoHandle>(null)
  151. const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
  152. const prevExpandedRowsRef = useRef<Record<number, boolean>>()
  153. const scrollContainerRef = useRef<HTMLDivElement>(null)
  154. const disableAutoScrollRef = useRef(false)
  155. const [showScrollToBottom, setShowScrollToBottom] = useState(false)
  156. const [isAtBottom, setIsAtBottom] = useState(false)
  157. const lastTtsRef = useRef<string>("")
  158. const [wasStreaming, setWasStreaming] = useState<boolean>(false)
  159. const [showCheckpointWarning, setShowCheckpointWarning] = useState<boolean>(false)
  160. const [isCondensing, setIsCondensing] = useState<boolean>(false)
  161. const [showAnnouncementModal, setShowAnnouncementModal] = useState(false)
  162. const everVisibleMessagesTsRef = useRef<LRUCache<number, boolean>>(
  163. new LRUCache({
  164. max: 100,
  165. ttl: 1000 * 60 * 5,
  166. }),
  167. )
  168. const autoApproveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
  169. const userRespondedRef = useRef<boolean>(false)
  170. const [currentFollowUpTs, setCurrentFollowUpTs] = useState<number | null>(null)
  171. const clineAskRef = useRef(clineAsk)
  172. useEffect(() => {
  173. clineAskRef.current = clineAsk
  174. }, [clineAsk])
  175. useEffect(() => {
  176. isMountedRef.current = true
  177. return () => {
  178. isMountedRef.current = false
  179. }
  180. }, [])
  181. const isProfileDisabled = useMemo(
  182. () => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList),
  183. [apiConfiguration, organizationAllowList],
  184. )
  185. // UI layout depends on the last 2 messages
  186. // (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
  187. const lastMessage = useMemo(() => messages.at(-1), [messages])
  188. const secondLastMessage = useMemo(() => messages.at(-2), [messages])
  189. // Setup sound hooks with use-sound
  190. const volume = typeof soundVolume === "number" ? soundVolume : 0.5
  191. const soundConfig = {
  192. volume,
  193. // useSound expects 'disabled' property, not 'soundEnabled'
  194. soundEnabled,
  195. }
  196. const getAudioUrl = (path: string) => {
  197. return `${audioBaseUri}/${path}`
  198. }
  199. // Use the getAudioUrl helper function
  200. const [playNotification] = useSound(getAudioUrl("notification.wav"), soundConfig)
  201. const [playCelebration] = useSound(getAudioUrl("celebration.wav"), soundConfig)
  202. const [playProgressLoop] = useSound(getAudioUrl("progress_loop.wav"), soundConfig)
  203. function playSound(audioType: AudioType) {
  204. // Play the appropriate sound based on type
  205. // The disabled state is handled by the useSound hook configuration
  206. switch (audioType) {
  207. case "notification":
  208. playNotification()
  209. break
  210. case "celebration":
  211. playCelebration()
  212. break
  213. case "progress_loop":
  214. playProgressLoop()
  215. break
  216. default:
  217. console.warn(`Unknown audio type: ${audioType}`)
  218. }
  219. }
  220. function playTts(text: string) {
  221. vscode.postMessage({ type: "playTts", text })
  222. }
  223. useDeepCompareEffect(() => {
  224. // if last message is an ask, show user ask UI
  225. // 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.
  226. // basically as long as a task is active, the conversation history will be persisted
  227. if (lastMessage) {
  228. switch (lastMessage.type) {
  229. case "ask":
  230. // Reset user response flag when a new ask arrives to allow auto-approval
  231. userRespondedRef.current = false
  232. const isPartial = lastMessage.partial === true
  233. switch (lastMessage.ask) {
  234. case "api_req_failed":
  235. playSound("progress_loop")
  236. setSendingDisabled(true)
  237. setClineAsk("api_req_failed")
  238. setEnableButtons(true)
  239. setPrimaryButtonText(t("chat:retry.title"))
  240. setSecondaryButtonText(t("chat:startNewTask.title"))
  241. break
  242. case "mistake_limit_reached":
  243. playSound("progress_loop")
  244. setSendingDisabled(false)
  245. setClineAsk("mistake_limit_reached")
  246. setEnableButtons(true)
  247. setPrimaryButtonText(t("chat:proceedAnyways.title"))
  248. setSecondaryButtonText(t("chat:startNewTask.title"))
  249. break
  250. case "followup":
  251. if (!isPartial) {
  252. playSound("notification")
  253. }
  254. setSendingDisabled(isPartial)
  255. setClineAsk("followup")
  256. // setting enable buttons to `false` would trigger a focus grab when
  257. // the text area is enabled which is undesirable.
  258. // We have no buttons for this tool, so no problem having them "enabled"
  259. // to workaround this issue. See #1358.
  260. setEnableButtons(true)
  261. setPrimaryButtonText(undefined)
  262. setSecondaryButtonText(undefined)
  263. break
  264. case "tool":
  265. if (!isAutoApproved(lastMessage) && !isPartial) {
  266. playSound("notification")
  267. }
  268. setSendingDisabled(isPartial)
  269. setClineAsk("tool")
  270. setEnableButtons(!isPartial)
  271. const tool = JSON.parse(lastMessage.text || "{}") as ClineSayTool
  272. switch (tool.tool) {
  273. case "editedExistingFile":
  274. case "appliedDiff":
  275. case "newFileCreated":
  276. case "insertContent":
  277. setPrimaryButtonText(t("chat:save.title"))
  278. setSecondaryButtonText(t("chat:reject.title"))
  279. break
  280. case "finishTask":
  281. setPrimaryButtonText(t("chat:completeSubtaskAndReturn"))
  282. setSecondaryButtonText(undefined)
  283. break
  284. case "readFile":
  285. if (tool.batchFiles && Array.isArray(tool.batchFiles)) {
  286. setPrimaryButtonText(t("chat:read-batch.approve.title"))
  287. setSecondaryButtonText(t("chat:read-batch.deny.title"))
  288. } else {
  289. setPrimaryButtonText(t("chat:approve.title"))
  290. setSecondaryButtonText(t("chat:reject.title"))
  291. }
  292. break
  293. default:
  294. setPrimaryButtonText(t("chat:approve.title"))
  295. setSecondaryButtonText(t("chat:reject.title"))
  296. break
  297. }
  298. break
  299. case "browser_action_launch":
  300. if (!isAutoApproved(lastMessage) && !isPartial) {
  301. playSound("notification")
  302. }
  303. setSendingDisabled(isPartial)
  304. setClineAsk("browser_action_launch")
  305. setEnableButtons(!isPartial)
  306. setPrimaryButtonText(t("chat:approve.title"))
  307. setSecondaryButtonText(t("chat:reject.title"))
  308. break
  309. case "command":
  310. if (!isAutoApproved(lastMessage) && !isPartial) {
  311. playSound("notification")
  312. }
  313. setSendingDisabled(isPartial)
  314. setClineAsk("command")
  315. setEnableButtons(!isPartial)
  316. setPrimaryButtonText(t("chat:runCommand.title"))
  317. setSecondaryButtonText(t("chat:reject.title"))
  318. break
  319. case "command_output":
  320. setSendingDisabled(false)
  321. setClineAsk("command_output")
  322. setEnableButtons(true)
  323. setPrimaryButtonText(t("chat:proceedWhileRunning.title"))
  324. setSecondaryButtonText(t("chat:killCommand.title"))
  325. break
  326. case "use_mcp_server":
  327. if (!isAutoApproved(lastMessage) && !isPartial) {
  328. playSound("notification")
  329. }
  330. setSendingDisabled(isPartial)
  331. setClineAsk("use_mcp_server")
  332. setEnableButtons(!isPartial)
  333. setPrimaryButtonText(t("chat:approve.title"))
  334. setSecondaryButtonText(t("chat:reject.title"))
  335. break
  336. case "completion_result":
  337. // extension waiting for feedback. but we can just present a new task button
  338. if (!isPartial) {
  339. playSound("celebration")
  340. }
  341. setSendingDisabled(isPartial)
  342. setClineAsk("completion_result")
  343. setEnableButtons(!isPartial)
  344. setPrimaryButtonText(t("chat:startNewTask.title"))
  345. setSecondaryButtonText(undefined)
  346. break
  347. case "resume_task":
  348. setSendingDisabled(false)
  349. setClineAsk("resume_task")
  350. setEnableButtons(true)
  351. setPrimaryButtonText(t("chat:resumeTask.title"))
  352. setSecondaryButtonText(t("chat:terminate.title"))
  353. setDidClickCancel(false) // special case where we reset the cancel button state
  354. break
  355. case "resume_completed_task":
  356. setSendingDisabled(false)
  357. setClineAsk("resume_completed_task")
  358. setEnableButtons(true)
  359. setPrimaryButtonText(t("chat:startNewTask.title"))
  360. setSecondaryButtonText(undefined)
  361. setDidClickCancel(false)
  362. break
  363. }
  364. break
  365. case "say":
  366. // Don't want to reset since there could be a "say" after
  367. // an "ask" while ask is waiting for response.
  368. switch (lastMessage.say) {
  369. case "api_req_retry_delayed":
  370. setSendingDisabled(true)
  371. break
  372. case "api_req_started":
  373. if (secondLastMessage?.ask === "command_output") {
  374. setSendingDisabled(true)
  375. setSelectedImages([])
  376. setClineAsk(undefined)
  377. setEnableButtons(false)
  378. }
  379. break
  380. case "api_req_finished":
  381. case "error":
  382. case "text":
  383. case "browser_action":
  384. case "browser_action_result":
  385. case "command_output":
  386. case "mcp_server_request_started":
  387. case "mcp_server_response":
  388. case "completion_result":
  389. break
  390. }
  391. break
  392. }
  393. }
  394. }, [lastMessage, secondLastMessage])
  395. useEffect(() => {
  396. if (messages.length === 0) {
  397. setSendingDisabled(false)
  398. setClineAsk(undefined)
  399. setEnableButtons(false)
  400. setPrimaryButtonText(undefined)
  401. setSecondaryButtonText(undefined)
  402. }
  403. }, [messages.length])
  404. useEffect(() => {
  405. setExpandedRows({})
  406. everVisibleMessagesTsRef.current.clear() // Clear for new task
  407. setCurrentFollowUpTs(null) // Clear follow-up answered state for new task
  408. // Clear any pending auto-approval timeout from previous task
  409. if (autoApproveTimeoutRef.current) {
  410. clearTimeout(autoApproveTimeoutRef.current)
  411. autoApproveTimeoutRef.current = null
  412. }
  413. // Reset user response flag for new task
  414. userRespondedRef.current = false
  415. // Clear message queue when starting a new task
  416. setMessageQueue([])
  417. // Clear retry counts
  418. retryCountRef.current.clear()
  419. }, [task?.ts])
  420. useEffect(() => {
  421. if (isHidden) {
  422. everVisibleMessagesTsRef.current.clear()
  423. }
  424. }, [isHidden])
  425. useEffect(() => {
  426. const cache = everVisibleMessagesTsRef.current
  427. return () => {
  428. cache.clear()
  429. }
  430. }, [])
  431. useEffect(() => {
  432. const prev = prevExpandedRowsRef.current
  433. let wasAnyRowExpandedByUser = false
  434. if (prev) {
  435. // Check if any row transitioned from false/undefined to true
  436. for (const [tsKey, isExpanded] of Object.entries(expandedRows)) {
  437. const ts = Number(tsKey)
  438. if (isExpanded && !(prev[ts] ?? false)) {
  439. wasAnyRowExpandedByUser = true
  440. break
  441. }
  442. }
  443. }
  444. if (wasAnyRowExpandedByUser) {
  445. disableAutoScrollRef.current = true
  446. }
  447. prevExpandedRowsRef.current = expandedRows // Store current state for next comparison
  448. }, [expandedRows])
  449. const isStreaming = useMemo(() => {
  450. // Checking clineAsk isn't enough since messages effect may be called
  451. // again for a tool for example, set clineAsk to its value, and if the
  452. // next message is not an ask then it doesn't reset. This is likely due
  453. // to how much more often we're updating messages as compared to before,
  454. // and should be resolved with optimizations as it's likely a rendering
  455. // bug. But as a final guard for now, the cancel button will show if the
  456. // last message is not an ask.
  457. const isLastAsk = !!modifiedMessages.at(-1)?.ask
  458. const isToolCurrentlyAsking =
  459. isLastAsk && clineAsk !== undefined && enableButtons && primaryButtonText !== undefined
  460. if (isToolCurrentlyAsking) {
  461. return false
  462. }
  463. const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true
  464. if (isLastMessagePartial) {
  465. return true
  466. } else {
  467. const lastApiReqStarted = findLast(
  468. modifiedMessages,
  469. (message: ClineMessage) => message.say === "api_req_started",
  470. )
  471. if (
  472. lastApiReqStarted &&
  473. lastApiReqStarted.text !== null &&
  474. lastApiReqStarted.text !== undefined &&
  475. lastApiReqStarted.say === "api_req_started"
  476. ) {
  477. const cost = JSON.parse(lastApiReqStarted.text).cost
  478. if (cost === undefined) {
  479. return true // API request has not finished yet.
  480. }
  481. }
  482. }
  483. return false
  484. }, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
  485. const markFollowUpAsAnswered = useCallback(() => {
  486. const lastFollowUpMessage = messagesRef.current.findLast((msg: ClineMessage) => msg.ask === "followup")
  487. if (lastFollowUpMessage) {
  488. setCurrentFollowUpTs(lastFollowUpMessage.ts)
  489. }
  490. }, [])
  491. const handleChatReset = useCallback(() => {
  492. // Clear any pending auto-approval timeout
  493. if (autoApproveTimeoutRef.current) {
  494. clearTimeout(autoApproveTimeoutRef.current)
  495. autoApproveTimeoutRef.current = null
  496. }
  497. // Reset user response flag for new message
  498. userRespondedRef.current = false
  499. // Only reset message-specific state, preserving mode.
  500. setInputValue("")
  501. setSendingDisabled(true)
  502. setSelectedImages([])
  503. setClineAsk(undefined)
  504. setEnableButtons(false)
  505. // Do not reset mode here as it should persist.
  506. // setPrimaryButtonText(undefined)
  507. // setSecondaryButtonText(undefined)
  508. disableAutoScrollRef.current = false
  509. }, [])
  510. /**
  511. * Handles sending messages to the extension
  512. * @param text - The message text to send
  513. * @param images - Array of image data URLs to send with the message
  514. * @param fromQueue - Internal flag indicating if this message is being sent from the queue (prevents re-queueing)
  515. */
  516. const handleSendMessage = useCallback(
  517. (text: string, images: string[], fromQueue = false) => {
  518. try {
  519. text = text.trim()
  520. if (text || images.length > 0) {
  521. if (sendingDisabled && !fromQueue) {
  522. // Generate a more unique ID using timestamp + random component
  523. const messageId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
  524. setMessageQueue((prev: QueuedMessage[]) => [...prev, { id: messageId, text, images }])
  525. setInputValue("")
  526. setSelectedImages([])
  527. return
  528. }
  529. // Mark that user has responded - this prevents any pending auto-approvals
  530. userRespondedRef.current = true
  531. if (messagesRef.current.length === 0) {
  532. vscode.postMessage({ type: "newTask", text, images })
  533. } else if (clineAskRef.current) {
  534. if (clineAskRef.current === "followup") {
  535. markFollowUpAsAnswered()
  536. }
  537. // Use clineAskRef.current
  538. switch (
  539. clineAskRef.current // Use clineAskRef.current
  540. ) {
  541. case "followup":
  542. case "tool":
  543. case "browser_action_launch":
  544. case "command": // User can provide feedback to a tool or command use.
  545. case "command_output": // User can send input to command stdin.
  546. case "use_mcp_server":
  547. case "completion_result": // If this happens then the user has feedback for the completion result.
  548. case "resume_task":
  549. case "resume_completed_task":
  550. case "mistake_limit_reached":
  551. vscode.postMessage({
  552. type: "askResponse",
  553. askResponse: "messageResponse",
  554. text,
  555. images,
  556. })
  557. break
  558. // There is no other case that a textfield should be enabled.
  559. }
  560. } else {
  561. // This is a new message in an ongoing task.
  562. vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images })
  563. }
  564. handleChatReset()
  565. }
  566. } catch (error) {
  567. console.error("Error in handleSendMessage:", error)
  568. // If this was a queued message, we should handle it differently
  569. if (fromQueue) {
  570. throw error // Re-throw to be caught by the queue processor
  571. }
  572. // For direct sends, we could show an error to the user
  573. // but for now we'll just log it
  574. }
  575. },
  576. [handleChatReset, markFollowUpAsAnswered, sendingDisabled], // messagesRef and clineAskRef are stable
  577. )
  578. useEffect(() => {
  579. // Early return if conditions aren't met
  580. // Also don't process queue if there's an API error (clineAsk === "api_req_failed")
  581. if (
  582. sendingDisabled ||
  583. messageQueue.length === 0 ||
  584. isProcessingQueueRef.current ||
  585. clineAsk === "api_req_failed"
  586. ) {
  587. return
  588. }
  589. // Mark as processing immediately to prevent race conditions
  590. isProcessingQueueRef.current = true
  591. // Process the first message in the queue
  592. const [nextMessage, ...remaining] = messageQueue
  593. // Update queue immediately to prevent duplicate processing
  594. setMessageQueue(remaining)
  595. // Process the message
  596. Promise.resolve()
  597. .then(() => {
  598. handleSendMessage(nextMessage.text, nextMessage.images, true)
  599. // Clear retry count on success
  600. retryCountRef.current.delete(nextMessage.id)
  601. })
  602. .catch((error) => {
  603. console.error("Failed to send queued message:", error)
  604. // Get current retry count
  605. const retryCount = retryCountRef.current.get(nextMessage.id) || 0
  606. // Only re-add if under retry limit
  607. if (retryCount < MAX_RETRY_ATTEMPTS) {
  608. retryCountRef.current.set(nextMessage.id, retryCount + 1)
  609. // Re-add the message to the end of the queue
  610. setMessageQueue((current: QueuedMessage[]) => [...current, nextMessage])
  611. } else {
  612. console.error(`Message ${nextMessage.id} failed after ${MAX_RETRY_ATTEMPTS} attempts, discarding`)
  613. retryCountRef.current.delete(nextMessage.id)
  614. }
  615. })
  616. .finally(() => {
  617. isProcessingQueueRef.current = false
  618. })
  619. // Cleanup function to handle component unmount
  620. return () => {
  621. isProcessingQueueRef.current = false
  622. }
  623. }, [sendingDisabled, messageQueue, handleSendMessage, clineAsk])
  624. const handleSetChatBoxMessage = useCallback(
  625. (text: string, images: string[]) => {
  626. // Avoid nested template literals by breaking down the logic
  627. let newValue = text
  628. if (inputValue !== "") {
  629. newValue = inputValue + " " + text
  630. }
  631. setInputValue(newValue)
  632. setSelectedImages([...selectedImages, ...images])
  633. },
  634. [inputValue, selectedImages],
  635. )
  636. // Cleanup retry count map on unmount
  637. useEffect(() => {
  638. // Store refs in variables to avoid stale closure issues
  639. const retryCountMap = retryCountRef.current
  640. const isProcessingRef = isProcessingQueueRef
  641. return () => {
  642. retryCountMap.clear()
  643. isProcessingRef.current = false
  644. }
  645. }, [])
  646. const startNewTask = useCallback(() => vscode.postMessage({ type: "clearTask" }), [])
  647. // This logic depends on the useEffect[messages] above to set clineAsk,
  648. // after which buttons are shown and we then send an askResponse to the
  649. // extension.
  650. const handlePrimaryButtonClick = useCallback(
  651. (text?: string, images?: string[]) => {
  652. // Mark that user has responded
  653. userRespondedRef.current = true
  654. const trimmedInput = text?.trim()
  655. switch (clineAsk) {
  656. case "api_req_failed":
  657. case "command":
  658. case "tool":
  659. case "browser_action_launch":
  660. case "use_mcp_server":
  661. case "resume_task":
  662. case "mistake_limit_reached":
  663. // Only send text/images if they exist
  664. if (trimmedInput || (images && images.length > 0)) {
  665. vscode.postMessage({
  666. type: "askResponse",
  667. askResponse: "yesButtonClicked",
  668. text: trimmedInput,
  669. images: images,
  670. })
  671. // Clear input state after sending
  672. setInputValue("")
  673. setSelectedImages([])
  674. } else {
  675. vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
  676. }
  677. break
  678. case "completion_result":
  679. case "resume_completed_task":
  680. // Waiting for feedback, but we can just present a new task button
  681. startNewTask()
  682. break
  683. case "command_output":
  684. vscode.postMessage({ type: "terminalOperation", terminalOperation: "continue" })
  685. break
  686. }
  687. setSendingDisabled(true)
  688. setClineAsk(undefined)
  689. setEnableButtons(false)
  690. },
  691. [clineAsk, startNewTask],
  692. )
  693. const handleSecondaryButtonClick = useCallback(
  694. (text?: string, images?: string[]) => {
  695. // Mark that user has responded
  696. userRespondedRef.current = true
  697. const trimmedInput = text?.trim()
  698. if (isStreaming) {
  699. vscode.postMessage({ type: "cancelTask" })
  700. setDidClickCancel(true)
  701. return
  702. }
  703. switch (clineAsk) {
  704. case "api_req_failed":
  705. case "mistake_limit_reached":
  706. case "resume_task":
  707. startNewTask()
  708. break
  709. case "command":
  710. case "tool":
  711. case "browser_action_launch":
  712. case "use_mcp_server":
  713. // Only send text/images if they exist
  714. if (trimmedInput || (images && images.length > 0)) {
  715. vscode.postMessage({
  716. type: "askResponse",
  717. askResponse: "noButtonClicked",
  718. text: trimmedInput,
  719. images: images,
  720. })
  721. // Clear input state after sending
  722. setInputValue("")
  723. setSelectedImages([])
  724. } else {
  725. // Responds to the API with a "This operation failed" and lets it try again
  726. vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" })
  727. }
  728. break
  729. case "command_output":
  730. vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" })
  731. break
  732. }
  733. setSendingDisabled(true)
  734. setClineAsk(undefined)
  735. setEnableButtons(false)
  736. },
  737. [clineAsk, startNewTask, isStreaming],
  738. )
  739. const { info: model } = useSelectedModel(apiConfiguration)
  740. const selectImages = useCallback(() => vscode.postMessage({ type: "selectImages" }), [])
  741. const shouldDisableImages = !model?.supportsImages || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
  742. const handleMessage = useCallback(
  743. (e: MessageEvent) => {
  744. const message: ExtensionMessage = e.data
  745. switch (message.type) {
  746. case "action":
  747. switch (message.action!) {
  748. case "didBecomeVisible":
  749. if (!isHidden && !sendingDisabled && !enableButtons) {
  750. textAreaRef.current?.focus()
  751. }
  752. break
  753. case "focusInput":
  754. textAreaRef.current?.focus()
  755. break
  756. }
  757. break
  758. case "selectedImages":
  759. // Only handle selectedImages if it's not for editing context
  760. // When context is "edit", ChatRow will handle the images
  761. if (message.context !== "edit") {
  762. setSelectedImages((prevImages: string[]) =>
  763. appendImages(prevImages, message.images, MAX_IMAGES_PER_MESSAGE),
  764. )
  765. }
  766. break
  767. case "invoke":
  768. switch (message.invoke!) {
  769. case "newChat":
  770. handleChatReset()
  771. break
  772. case "sendMessage":
  773. handleSendMessage(message.text ?? "", message.images ?? [])
  774. break
  775. case "setChatBoxMessage":
  776. handleSetChatBoxMessage(message.text ?? "", message.images ?? [])
  777. break
  778. case "primaryButtonClick":
  779. handlePrimaryButtonClick(message.text ?? "", message.images ?? [])
  780. break
  781. case "secondaryButtonClick":
  782. handleSecondaryButtonClick(message.text ?? "", message.images ?? [])
  783. break
  784. }
  785. break
  786. case "condenseTaskContextResponse":
  787. if (message.text && message.text === currentTaskItem?.id) {
  788. if (isCondensing && sendingDisabled) {
  789. setSendingDisabled(false)
  790. }
  791. setIsCondensing(false)
  792. }
  793. break
  794. }
  795. // textAreaRef.current is not explicitly required here since React
  796. // guarantees that ref will be stable across re-renders, and we're
  797. // not using its value but its reference.
  798. },
  799. [
  800. isCondensing,
  801. isHidden,
  802. sendingDisabled,
  803. enableButtons,
  804. currentTaskItem,
  805. handleChatReset,
  806. handleSendMessage,
  807. handleSetChatBoxMessage,
  808. handlePrimaryButtonClick,
  809. handleSecondaryButtonClick,
  810. ],
  811. )
  812. useEvent("message", handleMessage)
  813. // NOTE: the VSCode window needs to be focused for this to work.
  814. useMount(() => textAreaRef.current?.focus())
  815. const visibleMessages = useMemo(() => {
  816. // Remove the 500-message limit to prevent array index shifting
  817. // Virtuoso is designed to efficiently handle large lists through virtualization
  818. const newVisibleMessages = modifiedMessages.filter((message: ClineMessage) => {
  819. if (everVisibleMessagesTsRef.current.has(message.ts)) {
  820. const alwaysHiddenOnceProcessedAsk: ClineAsk[] = [
  821. "api_req_failed",
  822. "resume_task",
  823. "resume_completed_task",
  824. ]
  825. const alwaysHiddenOnceProcessedSay = [
  826. "api_req_finished",
  827. "api_req_retried",
  828. "api_req_deleted",
  829. "mcp_server_request_started",
  830. ]
  831. if (message.ask && alwaysHiddenOnceProcessedAsk.includes(message.ask)) return false
  832. if (message.say && alwaysHiddenOnceProcessedSay.includes(message.say)) return false
  833. if (message.say === "text" && (message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
  834. return false
  835. }
  836. return true
  837. }
  838. switch (message.ask) {
  839. case "completion_result":
  840. if (message.text === "") return false
  841. break
  842. case "api_req_failed":
  843. case "resume_task":
  844. case "resume_completed_task":
  845. return false
  846. }
  847. switch (message.say) {
  848. case "api_req_finished":
  849. case "api_req_retried":
  850. case "api_req_deleted":
  851. return false
  852. case "api_req_retry_delayed":
  853. const last1 = modifiedMessages.at(-1)
  854. const last2 = modifiedMessages.at(-2)
  855. if (last1?.ask === "resume_task" && last2 === message) {
  856. return true
  857. } else if (message !== last1) {
  858. return false
  859. }
  860. break
  861. case "text":
  862. if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) return false
  863. break
  864. case "mcp_server_request_started":
  865. return false
  866. }
  867. return true
  868. })
  869. const viewportStart = Math.max(0, newVisibleMessages.length - 100)
  870. newVisibleMessages
  871. .slice(viewportStart)
  872. .forEach((msg: ClineMessage) => everVisibleMessagesTsRef.current.set(msg.ts, true))
  873. return newVisibleMessages
  874. }, [modifiedMessages])
  875. useEffect(() => {
  876. const cleanupInterval = setInterval(() => {
  877. const cache = everVisibleMessagesTsRef.current
  878. const currentMessageIds = new Set(modifiedMessages.map((m: ClineMessage) => m.ts))
  879. const viewportMessages = visibleMessages.slice(Math.max(0, visibleMessages.length - 100))
  880. const viewportMessageIds = new Set(viewportMessages.map((m: ClineMessage) => m.ts))
  881. cache.forEach((_value: boolean, key: number) => {
  882. if (!currentMessageIds.has(key) && !viewportMessageIds.has(key)) {
  883. cache.delete(key)
  884. }
  885. })
  886. }, 60000)
  887. return () => clearInterval(cleanupInterval)
  888. }, [modifiedMessages, visibleMessages])
  889. useDebounceEffect(
  890. () => {
  891. if (!isHidden && !sendingDisabled && !enableButtons) {
  892. textAreaRef.current?.focus()
  893. }
  894. },
  895. 50,
  896. [isHidden, sendingDisabled, enableButtons],
  897. )
  898. const isReadOnlyToolAction = useCallback((message: ClineMessage | undefined) => {
  899. if (message?.type === "ask") {
  900. if (!message.text) {
  901. return true
  902. }
  903. const tool = JSON.parse(message.text)
  904. return [
  905. "readFile",
  906. "listFiles",
  907. "listFilesTopLevel",
  908. "listFilesRecursive",
  909. "listCodeDefinitionNames",
  910. "searchFiles",
  911. "codebaseSearch",
  912. ].includes(tool.tool)
  913. }
  914. return false
  915. }, [])
  916. const isWriteToolAction = useCallback((message: ClineMessage | undefined) => {
  917. if (message?.type === "ask") {
  918. if (!message.text) {
  919. return true
  920. }
  921. const tool = JSON.parse(message.text)
  922. return [
  923. "editedExistingFile",
  924. "appliedDiff",
  925. "newFileCreated",
  926. "searchAndReplace",
  927. "insertContent",
  928. ].includes(tool.tool)
  929. }
  930. return false
  931. }, [])
  932. const isMcpToolAlwaysAllowed = useCallback(
  933. (message: ClineMessage | undefined) => {
  934. if (message?.type === "ask" && message.ask === "use_mcp_server") {
  935. if (!message.text) {
  936. return true
  937. }
  938. const mcpServerUse = JSON.parse(message.text) as { type: string; serverName: string; toolName: string }
  939. if (mcpServerUse.type === "use_mcp_tool") {
  940. const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
  941. const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName)
  942. return tool?.alwaysAllow || false
  943. }
  944. }
  945. return false
  946. },
  947. [mcpServers],
  948. )
  949. // Get the command decision using unified validation logic
  950. const getCommandDecisionForMessage = useCallback(
  951. (message: ClineMessage | undefined): CommandDecision => {
  952. if (message?.type !== "ask") return "ask_user"
  953. return getCommandDecision(message.text || "", allowedCommands || [], deniedCommands || [])
  954. },
  955. [allowedCommands, deniedCommands],
  956. )
  957. // Check if a command message should be auto-approved.
  958. const isAllowedCommand = useCallback(
  959. (message: ClineMessage | undefined): boolean => {
  960. return getCommandDecisionForMessage(message) === "auto_approve"
  961. },
  962. [getCommandDecisionForMessage],
  963. )
  964. // Check if a command message should be auto-denied.
  965. const isDeniedCommand = useCallback(
  966. (message: ClineMessage | undefined): boolean => {
  967. return getCommandDecisionForMessage(message) === "auto_deny"
  968. },
  969. [getCommandDecisionForMessage],
  970. )
  971. // Helper function to get the denied prefix for a command
  972. const getDeniedPrefix = useCallback(
  973. (command: string): string | null => {
  974. if (!command || !deniedCommands?.length) return null
  975. // Parse the command into sub-commands and check each one
  976. const subCommands = parseCommand(command)
  977. for (const cmd of subCommands) {
  978. const deniedMatch = findLongestPrefixMatch(cmd, deniedCommands)
  979. if (deniedMatch) {
  980. return deniedMatch
  981. }
  982. }
  983. return null
  984. },
  985. [deniedCommands],
  986. )
  987. // Create toggles object for useAutoApprovalState hook
  988. const autoApprovalToggles = useAutoApprovalToggles()
  989. const { hasEnabledOptions } = useAutoApprovalState(autoApprovalToggles, autoApprovalEnabled)
  990. const isAutoApproved = useCallback(
  991. (message: ClineMessage | undefined) => {
  992. // First check if auto-approval is enabled AND we have at least one permission
  993. if (!autoApprovalEnabled || !message || message.type !== "ask") {
  994. return false
  995. }
  996. // Use the hook's result instead of duplicating the logic
  997. if (!hasEnabledOptions) {
  998. return false
  999. }
  1000. if (message.ask === "followup") {
  1001. return alwaysAllowFollowupQuestions
  1002. }
  1003. if (message.ask === "browser_action_launch") {
  1004. return alwaysAllowBrowser
  1005. }
  1006. if (message.ask === "use_mcp_server") {
  1007. return alwaysAllowMcp && isMcpToolAlwaysAllowed(message)
  1008. }
  1009. if (message.ask === "command") {
  1010. return alwaysAllowExecute && isAllowedCommand(message)
  1011. }
  1012. // For read/write operations, check if it's outside workspace and if
  1013. // we have permission for that.
  1014. if (message.ask === "tool") {
  1015. let tool: any = {}
  1016. try {
  1017. tool = JSON.parse(message.text || "{}")
  1018. } catch (error) {
  1019. console.error("Failed to parse tool:", error)
  1020. }
  1021. if (!tool) {
  1022. return false
  1023. }
  1024. if (tool?.tool === "updateTodoList") {
  1025. return alwaysAllowUpdateTodoList
  1026. }
  1027. if (tool?.tool === "fetchInstructions") {
  1028. if (tool.content === "create_mode") {
  1029. return alwaysAllowModeSwitch
  1030. }
  1031. if (tool.content === "create_mcp_server") {
  1032. return alwaysAllowMcp
  1033. }
  1034. }
  1035. if (tool?.tool === "switchMode") {
  1036. return alwaysAllowModeSwitch
  1037. }
  1038. if (["newTask", "finishTask"].includes(tool?.tool)) {
  1039. return alwaysAllowSubtasks
  1040. }
  1041. const isOutsideWorkspace = !!tool.isOutsideWorkspace
  1042. const isProtected = message.isProtected
  1043. if (isReadOnlyToolAction(message)) {
  1044. return alwaysAllowReadOnly && (!isOutsideWorkspace || alwaysAllowReadOnlyOutsideWorkspace)
  1045. }
  1046. if (isWriteToolAction(message)) {
  1047. return (
  1048. alwaysAllowWrite &&
  1049. (!isOutsideWorkspace || alwaysAllowWriteOutsideWorkspace) &&
  1050. (!isProtected || alwaysAllowWriteProtected)
  1051. )
  1052. }
  1053. }
  1054. return false
  1055. },
  1056. [
  1057. autoApprovalEnabled,
  1058. hasEnabledOptions,
  1059. alwaysAllowBrowser,
  1060. alwaysAllowReadOnly,
  1061. alwaysAllowReadOnlyOutsideWorkspace,
  1062. isReadOnlyToolAction,
  1063. alwaysAllowWrite,
  1064. alwaysAllowWriteOutsideWorkspace,
  1065. alwaysAllowWriteProtected,
  1066. isWriteToolAction,
  1067. alwaysAllowExecute,
  1068. isAllowedCommand,
  1069. alwaysAllowMcp,
  1070. isMcpToolAlwaysAllowed,
  1071. alwaysAllowModeSwitch,
  1072. alwaysAllowFollowupQuestions,
  1073. alwaysAllowSubtasks,
  1074. alwaysAllowUpdateTodoList,
  1075. ],
  1076. )
  1077. useEffect(() => {
  1078. // This ensures the first message is not read, future user messages are
  1079. // labeled as `user_feedback`.
  1080. if (lastMessage && messages.length > 1) {
  1081. if (
  1082. lastMessage.text && // has text
  1083. (lastMessage.say === "text" || lastMessage.say === "completion_result") && // is a text message
  1084. !lastMessage.partial && // not a partial message
  1085. !lastMessage.text.startsWith("{") // not a json object
  1086. ) {
  1087. let text = lastMessage?.text || ""
  1088. const mermaidRegex = /```mermaid[\s\S]*?```/g
  1089. // remove mermaid diagrams from text
  1090. text = text.replace(mermaidRegex, "")
  1091. // remove markdown from text
  1092. text = removeMd(text)
  1093. // ensure message is not a duplicate of last read message
  1094. if (text !== lastTtsRef.current) {
  1095. try {
  1096. playTts(text)
  1097. lastTtsRef.current = text
  1098. } catch (error) {
  1099. console.error("Failed to execute text-to-speech:", error)
  1100. }
  1101. }
  1102. }
  1103. }
  1104. // Update previous value.
  1105. setWasStreaming(isStreaming)
  1106. }, [isStreaming, lastMessage, wasStreaming, isAutoApproved, messages.length])
  1107. const isBrowserSessionMessage = (message: ClineMessage): boolean => {
  1108. // Which of visible messages are browser session messages, see above.
  1109. if (message.type === "ask") {
  1110. return ["browser_action_launch"].includes(message.ask!)
  1111. }
  1112. if (message.type === "say") {
  1113. return ["api_req_started", "text", "browser_action", "browser_action_result"].includes(message.say!)
  1114. }
  1115. return false
  1116. }
  1117. const groupedMessages = useMemo(() => {
  1118. const result: (ClineMessage | ClineMessage[])[] = []
  1119. let currentGroup: ClineMessage[] = []
  1120. let isInBrowserSession = false
  1121. const endBrowserSession = () => {
  1122. if (currentGroup.length > 0) {
  1123. result.push([...currentGroup])
  1124. currentGroup = []
  1125. isInBrowserSession = false
  1126. }
  1127. }
  1128. visibleMessages.forEach((message: ClineMessage) => {
  1129. if (message.ask === "browser_action_launch") {
  1130. // Complete existing browser session if any.
  1131. endBrowserSession()
  1132. // Start new.
  1133. isInBrowserSession = true
  1134. currentGroup.push(message)
  1135. } else if (isInBrowserSession) {
  1136. // End session if `api_req_started` is cancelled.
  1137. if (message.say === "api_req_started") {
  1138. // Get last `api_req_started` in currentGroup to check if
  1139. // it's cancelled. If it is then this api req is not part
  1140. // of the current browser session.
  1141. const lastApiReqStarted = [...currentGroup].reverse().find((m) => m.say === "api_req_started")
  1142. if (lastApiReqStarted?.text !== null && lastApiReqStarted?.text !== undefined) {
  1143. const info = JSON.parse(lastApiReqStarted.text)
  1144. const isCancelled = info.cancelReason !== null && info.cancelReason !== undefined
  1145. if (isCancelled) {
  1146. endBrowserSession()
  1147. result.push(message)
  1148. return
  1149. }
  1150. }
  1151. }
  1152. if (isBrowserSessionMessage(message)) {
  1153. currentGroup.push(message)
  1154. // Check if this is a close action
  1155. if (message.say === "browser_action") {
  1156. const browserAction = JSON.parse(message.text || "{}") as ClineSayBrowserAction
  1157. if (browserAction.action === "close") {
  1158. endBrowserSession()
  1159. }
  1160. }
  1161. } else {
  1162. // complete existing browser session if any
  1163. endBrowserSession()
  1164. result.push(message)
  1165. }
  1166. } else {
  1167. result.push(message)
  1168. }
  1169. })
  1170. // Handle case where browser session is the last group
  1171. if (currentGroup.length > 0) {
  1172. result.push([...currentGroup])
  1173. }
  1174. if (isCondensing) {
  1175. // Show indicator after clicking condense button
  1176. result.push({
  1177. type: "say",
  1178. say: "condense_context",
  1179. ts: Date.now(),
  1180. partial: true,
  1181. })
  1182. }
  1183. return result
  1184. }, [isCondensing, visibleMessages])
  1185. // scrolling
  1186. const scrollToBottomSmooth = useMemo(
  1187. () =>
  1188. debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, {
  1189. immediate: true,
  1190. }),
  1191. [],
  1192. )
  1193. useEffect(() => {
  1194. return () => {
  1195. if (scrollToBottomSmooth && typeof (scrollToBottomSmooth as any).cancel === "function") {
  1196. ;(scrollToBottomSmooth as any).cancel()
  1197. }
  1198. }
  1199. }, [scrollToBottomSmooth])
  1200. const scrollToBottomAuto = useCallback(() => {
  1201. virtuosoRef.current?.scrollTo({
  1202. top: Number.MAX_SAFE_INTEGER,
  1203. behavior: "auto", // Instant causes crash.
  1204. })
  1205. }, [])
  1206. const handleSetExpandedRow = useCallback(
  1207. (ts: number, expand?: boolean) => {
  1208. setExpandedRows((prev: Record<number, boolean>) => ({
  1209. ...prev,
  1210. [ts]: expand === undefined ? !prev[ts] : expand,
  1211. }))
  1212. },
  1213. [setExpandedRows], // setExpandedRows is stable
  1214. )
  1215. // Scroll when user toggles certain rows.
  1216. const toggleRowExpansion = useCallback(
  1217. (ts: number) => {
  1218. handleSetExpandedRow(ts)
  1219. // The logic to set disableAutoScrollRef.current = true on expansion
  1220. // is now handled by the useEffect hook that observes expandedRows.
  1221. },
  1222. [handleSetExpandedRow],
  1223. )
  1224. const handleRowHeightChange = useCallback(
  1225. (isTaller: boolean) => {
  1226. if (!disableAutoScrollRef.current) {
  1227. if (isTaller) {
  1228. scrollToBottomSmooth()
  1229. } else {
  1230. setTimeout(() => scrollToBottomAuto(), 0)
  1231. }
  1232. }
  1233. },
  1234. [scrollToBottomSmooth, scrollToBottomAuto],
  1235. )
  1236. useEffect(() => {
  1237. let timer: ReturnType<typeof setTimeout> | undefined
  1238. if (!disableAutoScrollRef.current) {
  1239. timer = setTimeout(() => scrollToBottomSmooth(), 50)
  1240. }
  1241. return () => {
  1242. if (timer) {
  1243. clearTimeout(timer)
  1244. }
  1245. }
  1246. }, [groupedMessages.length, scrollToBottomSmooth])
  1247. const handleWheel = useCallback((event: Event) => {
  1248. const wheelEvent = event as WheelEvent
  1249. if (wheelEvent.deltaY && wheelEvent.deltaY < 0) {
  1250. if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
  1251. // User scrolled up
  1252. disableAutoScrollRef.current = true
  1253. }
  1254. }
  1255. }, [])
  1256. useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance
  1257. // Effect to handle showing the checkpoint warning after a delay
  1258. useEffect(() => {
  1259. // Only show the warning when there's a task but no visible messages yet
  1260. if (task && modifiedMessages.length === 0 && !isStreaming && !isHidden) {
  1261. const timer = setTimeout(() => {
  1262. setShowCheckpointWarning(true)
  1263. }, 5000) // 5 seconds
  1264. return () => clearTimeout(timer)
  1265. } else {
  1266. setShowCheckpointWarning(false)
  1267. }
  1268. }, [task, modifiedMessages.length, isStreaming, isHidden])
  1269. // Effect to hide the checkpoint warning when messages appear
  1270. useEffect(() => {
  1271. if (modifiedMessages.length > 0 || isStreaming || isHidden) {
  1272. setShowCheckpointWarning(false)
  1273. }
  1274. }, [modifiedMessages.length, isStreaming, isHidden])
  1275. const placeholderText = task ? t("chat:typeMessage") : t("chat:typeTask")
  1276. // Function to switch to a specific mode
  1277. const switchToMode = useCallback(
  1278. (modeSlug: string): void => {
  1279. // Update local state and notify extension to sync mode change
  1280. setMode(modeSlug)
  1281. // Send the mode switch message
  1282. vscode.postMessage({
  1283. type: "mode",
  1284. text: modeSlug,
  1285. })
  1286. },
  1287. [setMode],
  1288. )
  1289. const handleSuggestionClickInRow = useCallback(
  1290. (suggestion: SuggestionItem, event?: React.MouseEvent) => {
  1291. // Mark that user has responded if this is a manual click (not auto-approval)
  1292. if (event) {
  1293. userRespondedRef.current = true
  1294. }
  1295. // Mark the current follow-up question as answered when a suggestion is clicked
  1296. if (clineAsk === "followup" && !event?.shiftKey) {
  1297. markFollowUpAsAnswered()
  1298. }
  1299. // Check if we need to switch modes
  1300. if (suggestion.mode) {
  1301. // Only switch modes if it's a manual click (event exists) or auto-approval is allowed
  1302. const isManualClick = !!event
  1303. if (isManualClick || alwaysAllowModeSwitch) {
  1304. // Switch mode without waiting
  1305. switchToMode(suggestion.mode)
  1306. }
  1307. }
  1308. if (event?.shiftKey) {
  1309. // Always append to existing text, don't overwrite
  1310. setInputValue((currentValue: string) => {
  1311. return currentValue !== "" ? `${currentValue} \n${suggestion.answer}` : suggestion.answer
  1312. })
  1313. } else {
  1314. handleSendMessage(suggestion.answer, [])
  1315. }
  1316. },
  1317. [handleSendMessage, setInputValue, switchToMode, alwaysAllowModeSwitch, clineAsk, markFollowUpAsAnswered],
  1318. )
  1319. const handleBatchFileResponse = useCallback((response: { [key: string]: boolean }) => {
  1320. // Handle batch file response, e.g., for file uploads
  1321. vscode.postMessage({ type: "askResponse", askResponse: "objectResponse", text: JSON.stringify(response) })
  1322. }, [])
  1323. // Handler for when FollowUpSuggest component unmounts
  1324. const handleFollowUpUnmount = useCallback(() => {
  1325. // Mark that user has responded
  1326. userRespondedRef.current = true
  1327. }, [])
  1328. const itemContent = useCallback(
  1329. (index: number, messageOrGroup: ClineMessage | ClineMessage[]) => {
  1330. // browser session group
  1331. if (Array.isArray(messageOrGroup)) {
  1332. return (
  1333. <BrowserSessionRow
  1334. messages={messageOrGroup}
  1335. isLast={index === groupedMessages.length - 1}
  1336. lastModifiedMessage={modifiedMessages.at(-1)}
  1337. onHeightChange={handleRowHeightChange}
  1338. isStreaming={isStreaming}
  1339. isExpanded={(messageTs: number) => expandedRows[messageTs] ?? false}
  1340. onToggleExpand={(messageTs: number) => {
  1341. setExpandedRows((prev: Record<number, boolean>) => ({
  1342. ...prev,
  1343. [messageTs]: !prev[messageTs],
  1344. }))
  1345. }}
  1346. />
  1347. )
  1348. }
  1349. // regular message
  1350. return (
  1351. <ChatRow
  1352. key={messageOrGroup.ts}
  1353. message={messageOrGroup}
  1354. isExpanded={expandedRows[messageOrGroup.ts] || false}
  1355. onToggleExpand={toggleRowExpansion} // This was already stabilized
  1356. lastModifiedMessage={modifiedMessages.at(-1)} // Original direct access
  1357. isLast={index === groupedMessages.length - 1} // Original direct access
  1358. onHeightChange={handleRowHeightChange}
  1359. isStreaming={isStreaming}
  1360. onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
  1361. onBatchFileResponse={handleBatchFileResponse}
  1362. onFollowUpUnmount={handleFollowUpUnmount}
  1363. isFollowUpAnswered={messageOrGroup.ts === currentFollowUpTs}
  1364. editable={
  1365. messageOrGroup.type === "ask" &&
  1366. messageOrGroup.ask === "tool" &&
  1367. (() => {
  1368. let tool: any = {}
  1369. try {
  1370. tool = JSON.parse(messageOrGroup.text || "{}")
  1371. } catch (_) {
  1372. if (messageOrGroup.text?.includes("updateTodoList")) {
  1373. tool = { tool: "updateTodoList" }
  1374. }
  1375. }
  1376. if (tool.tool === "updateTodoList" && alwaysAllowUpdateTodoList) {
  1377. return false
  1378. }
  1379. return tool.tool === "updateTodoList" && enableButtons && !!primaryButtonText
  1380. })()
  1381. }
  1382. />
  1383. )
  1384. },
  1385. [
  1386. expandedRows,
  1387. toggleRowExpansion,
  1388. modifiedMessages,
  1389. groupedMessages.length,
  1390. handleRowHeightChange,
  1391. isStreaming,
  1392. handleSuggestionClickInRow,
  1393. handleBatchFileResponse,
  1394. handleFollowUpUnmount,
  1395. currentFollowUpTs,
  1396. alwaysAllowUpdateTodoList,
  1397. enableButtons,
  1398. primaryButtonText,
  1399. ],
  1400. )
  1401. useEffect(() => {
  1402. if (autoApproveTimeoutRef.current) {
  1403. clearTimeout(autoApproveTimeoutRef.current)
  1404. autoApproveTimeoutRef.current = null
  1405. }
  1406. if (!clineAsk || !enableButtons) {
  1407. return
  1408. }
  1409. // Exit early if user has already responded
  1410. if (userRespondedRef.current) {
  1411. return
  1412. }
  1413. const autoApproveOrReject = async () => {
  1414. // Check for auto-reject first (commands that should be denied)
  1415. if (lastMessage?.ask === "command" && isDeniedCommand(lastMessage)) {
  1416. // Get the denied prefix for the localized message
  1417. const deniedPrefix = getDeniedPrefix(lastMessage.text || "")
  1418. if (deniedPrefix) {
  1419. // Create the localized auto-deny message and send it with the rejection
  1420. const autoDenyMessage = tSettings("autoApprove.execute.autoDenied", { prefix: deniedPrefix })
  1421. vscode.postMessage({
  1422. type: "askResponse",
  1423. askResponse: "noButtonClicked",
  1424. text: autoDenyMessage,
  1425. })
  1426. } else {
  1427. // Auto-reject denied commands immediately if no prefix found
  1428. vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" })
  1429. }
  1430. setSendingDisabled(true)
  1431. setClineAsk(undefined)
  1432. setEnableButtons(false)
  1433. return
  1434. }
  1435. // Then check for auto-approve
  1436. if (lastMessage?.ask && isAutoApproved(lastMessage)) {
  1437. // Special handling for follow-up questions
  1438. if (lastMessage.ask === "followup") {
  1439. // Handle invalid JSON
  1440. let followUpData: FollowUpData = {}
  1441. try {
  1442. followUpData = JSON.parse(lastMessage.text || "{}") as FollowUpData
  1443. } catch (error) {
  1444. console.error("Failed to parse follow-up data:", error)
  1445. return
  1446. }
  1447. if (followUpData && followUpData.suggest && followUpData.suggest.length > 0) {
  1448. // Wait for the configured timeout before auto-selecting the first suggestion
  1449. await new Promise<void>((resolve) => {
  1450. autoApproveTimeoutRef.current = setTimeout(() => {
  1451. autoApproveTimeoutRef.current = null
  1452. resolve()
  1453. }, followupAutoApproveTimeoutMs)
  1454. })
  1455. // Check if user responded manually
  1456. if (userRespondedRef.current) {
  1457. return
  1458. }
  1459. // Get the first suggestion
  1460. const firstSuggestion = followUpData.suggest[0]
  1461. // Handle the suggestion click
  1462. handleSuggestionClickInRow(firstSuggestion)
  1463. return
  1464. }
  1465. } else if (lastMessage.ask === "tool" && isWriteToolAction(lastMessage)) {
  1466. await new Promise<void>((resolve) => {
  1467. autoApproveTimeoutRef.current = setTimeout(() => {
  1468. autoApproveTimeoutRef.current = null
  1469. resolve()
  1470. }, writeDelayMs)
  1471. })
  1472. }
  1473. vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
  1474. setSendingDisabled(true)
  1475. setClineAsk(undefined)
  1476. setEnableButtons(false)
  1477. }
  1478. }
  1479. autoApproveOrReject()
  1480. return () => {
  1481. if (autoApproveTimeoutRef.current) {
  1482. clearTimeout(autoApproveTimeoutRef.current)
  1483. autoApproveTimeoutRef.current = null
  1484. }
  1485. }
  1486. }, [
  1487. clineAsk,
  1488. enableButtons,
  1489. handlePrimaryButtonClick,
  1490. alwaysAllowBrowser,
  1491. alwaysAllowReadOnly,
  1492. alwaysAllowReadOnlyOutsideWorkspace,
  1493. alwaysAllowWrite,
  1494. alwaysAllowWriteOutsideWorkspace,
  1495. alwaysAllowExecute,
  1496. followupAutoApproveTimeoutMs,
  1497. alwaysAllowMcp,
  1498. messages,
  1499. allowedCommands,
  1500. deniedCommands,
  1501. mcpServers,
  1502. isAutoApproved,
  1503. lastMessage,
  1504. writeDelayMs,
  1505. isWriteToolAction,
  1506. alwaysAllowFollowupQuestions,
  1507. handleSuggestionClickInRow,
  1508. isAllowedCommand,
  1509. isDeniedCommand,
  1510. getDeniedPrefix,
  1511. tSettings,
  1512. ])
  1513. // Function to handle mode switching
  1514. const switchToNextMode = useCallback(() => {
  1515. const allModes = getAllModes(customModes)
  1516. const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
  1517. const nextModeIndex = (currentModeIndex + 1) % allModes.length
  1518. // Update local state and notify extension to sync mode change
  1519. switchToMode(allModes[nextModeIndex].slug)
  1520. }, [mode, customModes, switchToMode])
  1521. // Function to handle switching to previous mode
  1522. const switchToPreviousMode = useCallback(() => {
  1523. const allModes = getAllModes(customModes)
  1524. const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
  1525. const previousModeIndex = (currentModeIndex - 1 + allModes.length) % allModes.length
  1526. // Update local state and notify extension to sync mode change
  1527. switchToMode(allModes[previousModeIndex].slug)
  1528. }, [mode, customModes, switchToMode])
  1529. // Add keyboard event handler
  1530. const handleKeyDown = useCallback(
  1531. (event: KeyboardEvent) => {
  1532. // Check for Command/Ctrl + Period (with or without Shift)
  1533. // Using event.key to respect keyboard layouts (e.g., Dvorak)
  1534. if ((event.metaKey || event.ctrlKey) && event.key === ".") {
  1535. event.preventDefault() // Prevent default browser behavior
  1536. if (event.shiftKey) {
  1537. // Shift + Period = Previous mode
  1538. switchToPreviousMode()
  1539. } else {
  1540. // Just Period = Next mode
  1541. switchToNextMode()
  1542. }
  1543. }
  1544. },
  1545. [switchToNextMode, switchToPreviousMode],
  1546. )
  1547. // Add event listener
  1548. useEffect(() => {
  1549. window.addEventListener("keydown", handleKeyDown)
  1550. return () => {
  1551. window.removeEventListener("keydown", handleKeyDown)
  1552. }
  1553. }, [handleKeyDown])
  1554. useImperativeHandle(ref, () => ({
  1555. acceptInput: () => {
  1556. if (enableButtons && primaryButtonText) {
  1557. handlePrimaryButtonClick(inputValue, selectedImages)
  1558. } else if (!sendingDisabled && !isProfileDisabled && (inputValue.trim() || selectedImages.length > 0)) {
  1559. handleSendMessage(inputValue, selectedImages)
  1560. }
  1561. },
  1562. }))
  1563. const handleCondenseContext = (taskId: string) => {
  1564. if (isCondensing || sendingDisabled) {
  1565. return
  1566. }
  1567. setIsCondensing(true)
  1568. setSendingDisabled(true)
  1569. vscode.postMessage({ type: "condenseTaskContextRequest", text: taskId })
  1570. }
  1571. const areButtonsVisible = showScrollToBottom || primaryButtonText || secondaryButtonText || isStreaming
  1572. return (
  1573. <div
  1574. data-testid="chat-view"
  1575. className={isHidden ? "hidden" : "fixed top-0 left-0 right-0 bottom-0 flex flex-col overflow-hidden"}>
  1576. {(showAnnouncement || showAnnouncementModal) && (
  1577. <Announcement
  1578. hideAnnouncement={() => {
  1579. if (showAnnouncementModal) {
  1580. setShowAnnouncementModal(false)
  1581. }
  1582. if (showAnnouncement) {
  1583. hideAnnouncement()
  1584. }
  1585. }}
  1586. />
  1587. )}
  1588. {task ? (
  1589. <>
  1590. <TaskHeader
  1591. task={task}
  1592. tokensIn={apiMetrics.totalTokensIn}
  1593. tokensOut={apiMetrics.totalTokensOut}
  1594. cacheWrites={apiMetrics.totalCacheWrites}
  1595. cacheReads={apiMetrics.totalCacheReads}
  1596. totalCost={apiMetrics.totalCost}
  1597. contextTokens={apiMetrics.contextTokens}
  1598. buttonsDisabled={sendingDisabled}
  1599. handleCondenseContext={handleCondenseContext}
  1600. todos={latestTodos}
  1601. />
  1602. {hasSystemPromptOverride && (
  1603. <div className="px-3">
  1604. <SystemPromptWarning />
  1605. </div>
  1606. )}
  1607. {showCheckpointWarning && (
  1608. <div className="px-3">
  1609. <CheckpointWarning />
  1610. </div>
  1611. )}
  1612. </>
  1613. ) : (
  1614. <div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 relative">
  1615. {/* Moved Task Bar Header Here */}
  1616. {tasks.length !== 0 && (
  1617. <div className="flex text-vscode-descriptionForeground w-full mx-auto px-5 pt-3">
  1618. <div className="flex items-center gap-1 cursor-pointer" onClick={toggleExpanded}>
  1619. {tasks.length < 10 && (
  1620. <span className={`font-medium text-xs `}>{t("history:recentTasks")}</span>
  1621. )}
  1622. <span
  1623. className={`codicon ${isExpanded ? "codicon-eye" : "codicon-eye-closed"} scale-90`}
  1624. />
  1625. </div>
  1626. </div>
  1627. )}
  1628. <div
  1629. className={` w-full flex flex-col gap-4 m-auto ${isExpanded && tasks.length > 0 ? "mt-0" : ""} px-3.5 min-[370px]:px-10 pt-5 transition-all duration-300`}>
  1630. {/* Version indicator in top-right corner - only on welcome screen */}
  1631. <VersionIndicator
  1632. onClick={() => setShowAnnouncementModal(true)}
  1633. className="absolute top-2 right-3 z-10"
  1634. />
  1635. <RooHero />
  1636. {telemetrySetting === "unset" && <TelemetryBanner />}
  1637. <div className="mb-2.5">
  1638. {cloudIsAuthenticated || taskHistory.length < 4 ? <RooTips /> : <RooCloudCTA />}
  1639. </div>
  1640. {/* Show the task history preview if expanded and tasks exist */}
  1641. {taskHistory.length > 0 && isExpanded && <HistoryPreview />}
  1642. </div>
  1643. </div>
  1644. )}
  1645. {/*
  1646. // Flex layout explanation:
  1647. // 1. Content div above uses flex: "1 1 0" to:
  1648. // - Grow to fill available space (flex-grow: 1)
  1649. // - Shrink when AutoApproveMenu needs space (flex-shrink: 1)
  1650. // - Start from zero size (flex-basis: 0) to ensure proper distribution
  1651. // minHeight: 0 allows it to shrink below its content height
  1652. //
  1653. // 2. AutoApproveMenu uses flex: "0 1 auto" to:
  1654. // - Not grow beyond its content (flex-grow: 0)
  1655. // - Shrink when viewport is small (flex-shrink: 1)
  1656. // - Use its content size as basis (flex-basis: auto)
  1657. // This ensures it takes its natural height when there's space
  1658. // but becomes scrollable when the viewport is too small
  1659. */}
  1660. {!task && (
  1661. <div className="mb-1 flex-initial min-h-0">
  1662. <AutoApproveMenu />
  1663. </div>
  1664. )}
  1665. {task && (
  1666. <>
  1667. <div className="grow flex" ref={scrollContainerRef}>
  1668. <Virtuoso
  1669. ref={virtuosoRef}
  1670. key={task.ts}
  1671. className="scrollable grow overflow-y-scroll mb-1"
  1672. increaseViewportBy={{ top: 3_000, bottom: 1000 }}
  1673. data={groupedMessages}
  1674. itemContent={itemContent}
  1675. atBottomStateChange={(isAtBottom: boolean) => {
  1676. setIsAtBottom(isAtBottom)
  1677. if (isAtBottom) {
  1678. disableAutoScrollRef.current = false
  1679. }
  1680. setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom)
  1681. }}
  1682. atBottomThreshold={10}
  1683. initialTopMostItemIndex={groupedMessages.length - 1}
  1684. />
  1685. </div>
  1686. <div className={`flex-initial min-h-0 ${!areButtonsVisible ? "mb-1" : ""}`}>
  1687. <AutoApproveMenu />
  1688. </div>
  1689. {areButtonsVisible && (
  1690. <div
  1691. className={`flex h-9 items-center mb-1 px-[15px] ${
  1692. showScrollToBottom
  1693. ? "opacity-100"
  1694. : enableButtons || (isStreaming && !didClickCancel)
  1695. ? "opacity-100"
  1696. : "opacity-50"
  1697. }`}>
  1698. {showScrollToBottom ? (
  1699. <StandardTooltip content={t("chat:scrollToBottom")}>
  1700. <VSCodeButton
  1701. appearance="secondary"
  1702. className="flex-[2]"
  1703. onClick={() => {
  1704. scrollToBottomSmooth()
  1705. disableAutoScrollRef.current = false
  1706. }}>
  1707. <span className="codicon codicon-chevron-down"></span>
  1708. </VSCodeButton>
  1709. </StandardTooltip>
  1710. ) : (
  1711. <>
  1712. {primaryButtonText && !isStreaming && (
  1713. <StandardTooltip
  1714. content={
  1715. primaryButtonText === t("chat:retry.title")
  1716. ? t("chat:retry.tooltip")
  1717. : primaryButtonText === t("chat:save.title")
  1718. ? t("chat:save.tooltip")
  1719. : primaryButtonText === t("chat:approve.title")
  1720. ? t("chat:approve.tooltip")
  1721. : primaryButtonText === t("chat:runCommand.title")
  1722. ? t("chat:runCommand.tooltip")
  1723. : primaryButtonText === t("chat:startNewTask.title")
  1724. ? t("chat:startNewTask.tooltip")
  1725. : primaryButtonText === t("chat:resumeTask.title")
  1726. ? t("chat:resumeTask.tooltip")
  1727. : primaryButtonText ===
  1728. t("chat:proceedAnyways.title")
  1729. ? t("chat:proceedAnyways.tooltip")
  1730. : primaryButtonText ===
  1731. t("chat:proceedWhileRunning.title")
  1732. ? t("chat:proceedWhileRunning.tooltip")
  1733. : undefined
  1734. }>
  1735. <VSCodeButton
  1736. appearance="primary"
  1737. disabled={!enableButtons}
  1738. className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
  1739. onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
  1740. {primaryButtonText}
  1741. </VSCodeButton>
  1742. </StandardTooltip>
  1743. )}
  1744. {(secondaryButtonText || isStreaming) && (
  1745. <StandardTooltip
  1746. content={
  1747. isStreaming
  1748. ? t("chat:cancel.tooltip")
  1749. : secondaryButtonText === t("chat:startNewTask.title")
  1750. ? t("chat:startNewTask.tooltip")
  1751. : secondaryButtonText === t("chat:reject.title")
  1752. ? t("chat:reject.tooltip")
  1753. : secondaryButtonText === t("chat:terminate.title")
  1754. ? t("chat:terminate.tooltip")
  1755. : undefined
  1756. }>
  1757. <VSCodeButton
  1758. appearance="secondary"
  1759. disabled={!enableButtons && !(isStreaming && !didClickCancel)}
  1760. className={isStreaming ? "flex-[2] ml-0" : "flex-1 ml-[6px]"}
  1761. onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
  1762. {isStreaming ? t("chat:cancel.title") : secondaryButtonText}
  1763. </VSCodeButton>
  1764. </StandardTooltip>
  1765. )}
  1766. </>
  1767. )}
  1768. </div>
  1769. )}
  1770. </>
  1771. )}
  1772. <QueuedMessages
  1773. queue={messageQueue}
  1774. onRemove={(index) => setMessageQueue((prev) => prev.filter((_, i) => i !== index))}
  1775. onUpdate={(index, newText) => {
  1776. setMessageQueue((prev) => prev.map((msg, i) => (i === index ? { ...msg, text: newText } : msg)))
  1777. }}
  1778. />
  1779. <ChatTextArea
  1780. ref={textAreaRef}
  1781. inputValue={inputValue}
  1782. setInputValue={setInputValue}
  1783. sendingDisabled={sendingDisabled || isProfileDisabled}
  1784. selectApiConfigDisabled={sendingDisabled && clineAsk !== "api_req_failed"}
  1785. placeholderText={placeholderText}
  1786. selectedImages={selectedImages}
  1787. setSelectedImages={setSelectedImages}
  1788. onSend={() => handleSendMessage(inputValue, selectedImages)}
  1789. onSelectImages={selectImages}
  1790. shouldDisableImages={shouldDisableImages}
  1791. onHeightChange={() => {
  1792. if (isAtBottom) {
  1793. scrollToBottomAuto()
  1794. }
  1795. }}
  1796. mode={mode}
  1797. setMode={setMode}
  1798. modeShortcutText={modeShortcutText}
  1799. />
  1800. {isProfileDisabled && (
  1801. <div className="px-3">
  1802. <ProfileViolationWarning />
  1803. </div>
  1804. )}
  1805. <div id="roo-portal" />
  1806. </div>
  1807. )
  1808. }
  1809. const ChatView = forwardRef(ChatViewComponent)
  1810. export default ChatView