ChatView.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833
  1. import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
  2. import debounce from "debounce"
  3. import { useCallback, useEffect, useMemo, useRef, useState } from "react"
  4. import { useDeepCompareEffect, useEvent, useMount } from "react-use"
  5. import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
  6. import styled from "styled-components"
  7. import {
  8. ClineAsk,
  9. ClineMessage,
  10. ClineSayBrowserAction,
  11. ClineSayTool,
  12. ExtensionMessage,
  13. } from "../../../../src/shared/ExtensionMessage"
  14. import { findLast } from "../../../../src/shared/array"
  15. import { combineApiRequests } from "../../../../src/shared/combineApiRequests"
  16. import { combineCommandSequences } from "../../../../src/shared/combineCommandSequences"
  17. import { getApiMetrics } from "../../../../src/shared/getApiMetrics"
  18. import { useExtensionState } from "../../context/ExtensionStateContext"
  19. import { vscode } from "../../utils/vscode"
  20. import HistoryPreview from "../history/HistoryPreview"
  21. import { normalizeApiConfiguration } from "../settings/ApiOptions"
  22. import Announcement from "./Announcement"
  23. import BrowserSessionRow from "./BrowserSessionRow"
  24. import ChatRow from "./ChatRow"
  25. import ChatTextArea from "./ChatTextArea"
  26. import TaskHeader from "./TaskHeader"
  27. interface ChatViewProps {
  28. isHidden: boolean
  29. showAnnouncement: boolean
  30. hideAnnouncement: () => void
  31. showHistoryView: () => void
  32. }
  33. export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
  34. const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
  35. const { version, clineMessages: messages, taskHistory, apiConfiguration } = useExtensionState()
  36. //const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
  37. const task = useMemo(() => messages.at(0), [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)
  38. const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
  39. // has to be after api_req_finished are all reduced into api_req_started messages
  40. const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
  41. const [inputValue, setInputValue] = useState("")
  42. const textAreaRef = useRef<HTMLTextAreaElement>(null)
  43. const [textAreaDisabled, setTextAreaDisabled] = useState(false)
  44. const [selectedImages, setSelectedImages] = useState<string[]>([])
  45. // 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)
  46. const [clineAsk, setClineAsk] = useState<ClineAsk | undefined>(undefined)
  47. const [enableButtons, setEnableButtons] = useState<boolean>(false)
  48. const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
  49. const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
  50. const [didClickCancel, setDidClickCancel] = useState(false)
  51. const virtuosoRef = useRef<VirtuosoHandle>(null)
  52. const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
  53. const scrollContainerRef = useRef<HTMLDivElement>(null)
  54. const disableAutoScrollRef = useRef(false)
  55. const [showScrollToBottom, setShowScrollToBottom] = useState(false)
  56. const [isAtBottom, setIsAtBottom] = useState(false)
  57. // UI layout depends on the last 2 messages
  58. // (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
  59. const lastMessage = useMemo(() => messages.at(-1), [messages])
  60. const secondLastMessage = useMemo(() => messages.at(-2), [messages])
  61. useDeepCompareEffect(() => {
  62. // if last message is an ask, show user ask UI
  63. // 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.
  64. // basically as long as a task is active, the conversation history will be persisted
  65. if (lastMessage) {
  66. switch (lastMessage.type) {
  67. case "ask":
  68. const isPartial = lastMessage.partial === true
  69. switch (lastMessage.ask) {
  70. case "api_req_failed":
  71. setTextAreaDisabled(true)
  72. setClineAsk("api_req_failed")
  73. setEnableButtons(true)
  74. setPrimaryButtonText("Retry")
  75. setSecondaryButtonText("Start New Task")
  76. break
  77. case "mistake_limit_reached":
  78. setTextAreaDisabled(false)
  79. setClineAsk("mistake_limit_reached")
  80. setEnableButtons(true)
  81. setPrimaryButtonText("Proceed Anyways")
  82. setSecondaryButtonText("Start New Task")
  83. break
  84. case "followup":
  85. setTextAreaDisabled(isPartial)
  86. setClineAsk("followup")
  87. setEnableButtons(isPartial)
  88. // setPrimaryButtonText(undefined)
  89. // setSecondaryButtonText(undefined)
  90. break
  91. case "tool":
  92. setTextAreaDisabled(isPartial)
  93. setClineAsk("tool")
  94. setEnableButtons(!isPartial)
  95. const tool = JSON.parse(lastMessage.text || "{}") as ClineSayTool
  96. switch (tool.tool) {
  97. case "editedExistingFile":
  98. case "newFileCreated":
  99. setPrimaryButtonText("Save")
  100. setSecondaryButtonText("Reject")
  101. break
  102. default:
  103. setPrimaryButtonText("Approve")
  104. setSecondaryButtonText("Reject")
  105. break
  106. }
  107. break
  108. case "browser_action_launch":
  109. setTextAreaDisabled(isPartial)
  110. setClineAsk("browser_action_launch")
  111. setEnableButtons(!isPartial)
  112. setPrimaryButtonText("Approve")
  113. setSecondaryButtonText("Reject")
  114. break
  115. case "command":
  116. setTextAreaDisabled(isPartial)
  117. setClineAsk("command")
  118. setEnableButtons(!isPartial)
  119. setPrimaryButtonText("Run Command")
  120. setSecondaryButtonText("Reject")
  121. break
  122. case "command_output":
  123. setTextAreaDisabled(false)
  124. setClineAsk("command_output")
  125. setEnableButtons(true)
  126. setPrimaryButtonText("Proceed While Running")
  127. setSecondaryButtonText(undefined)
  128. break
  129. case "completion_result":
  130. // extension waiting for feedback. but we can just present a new task button
  131. setTextAreaDisabled(isPartial)
  132. setClineAsk("completion_result")
  133. setEnableButtons(!isPartial)
  134. setPrimaryButtonText("Start New Task")
  135. setSecondaryButtonText(undefined)
  136. break
  137. case "resume_task":
  138. setTextAreaDisabled(false)
  139. setClineAsk("resume_task")
  140. setEnableButtons(true)
  141. setPrimaryButtonText("Resume Task")
  142. setSecondaryButtonText(undefined)
  143. setDidClickCancel(false) // special case where we reset the cancel button state
  144. break
  145. case "resume_completed_task":
  146. setTextAreaDisabled(false)
  147. setClineAsk("resume_completed_task")
  148. setEnableButtons(true)
  149. setPrimaryButtonText("Start New Task")
  150. setSecondaryButtonText(undefined)
  151. setDidClickCancel(false)
  152. break
  153. }
  154. break
  155. case "say":
  156. // don't want to reset since there could be a "say" after an "ask" while ask is waiting for response
  157. switch (lastMessage.say) {
  158. case "api_req_started":
  159. if (secondLastMessage?.ask === "command_output") {
  160. // if the last ask is a command_output, and we receive an api_req_started, then that means the command has finished and we don't need input from the user anymore (in every other case, the user has to interact with input field or buttons to continue, which does the following automatically)
  161. setInputValue("")
  162. setTextAreaDisabled(true)
  163. setSelectedImages([])
  164. setClineAsk(undefined)
  165. setEnableButtons(false)
  166. }
  167. break
  168. case "task":
  169. case "error":
  170. case "api_req_finished":
  171. case "text":
  172. case "browser_action":
  173. case "browser_action_result":
  174. case "command_output":
  175. case "completion_result":
  176. case "tool":
  177. break
  178. }
  179. break
  180. }
  181. } else {
  182. // this would get called after sending the first message, so we have to watch messages.length instead
  183. // No messages, so user has to submit a task
  184. // setTextAreaDisabled(false)
  185. // setClineAsk(undefined)
  186. // setPrimaryButtonText(undefined)
  187. // setSecondaryButtonText(undefined)
  188. }
  189. }, [lastMessage, secondLastMessage])
  190. useEffect(() => {
  191. if (messages.length === 0) {
  192. setTextAreaDisabled(false)
  193. setClineAsk(undefined)
  194. setEnableButtons(false)
  195. setPrimaryButtonText(undefined)
  196. setSecondaryButtonText(undefined)
  197. }
  198. }, [messages.length])
  199. useEffect(() => {
  200. setExpandedRows({})
  201. }, [task?.ts])
  202. const isStreaming = useMemo(() => {
  203. const isLastAsk = !!modifiedMessages.at(-1)?.ask // 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
  204. const isToolCurrentlyAsking =
  205. isLastAsk && clineAsk !== undefined && enableButtons && primaryButtonText !== undefined
  206. if (isToolCurrentlyAsking) {
  207. return false
  208. }
  209. const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true
  210. if (isLastMessagePartial) {
  211. return true
  212. } else {
  213. const lastApiReqStarted = findLast(modifiedMessages, (message) => message.say === "api_req_started")
  214. if (lastApiReqStarted && lastApiReqStarted.text != null && lastApiReqStarted.say === "api_req_started") {
  215. const cost = JSON.parse(lastApiReqStarted.text).cost
  216. if (cost === undefined) {
  217. // api request has not finished yet
  218. return true
  219. }
  220. }
  221. }
  222. return false
  223. }, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
  224. const handleSendMessage = useCallback(
  225. (text: string, images: string[]) => {
  226. text = text.trim()
  227. if (text || images.length > 0) {
  228. if (messages.length === 0) {
  229. vscode.postMessage({ type: "newTask", text, images })
  230. } else if (clineAsk) {
  231. switch (clineAsk) {
  232. case "followup":
  233. case "tool":
  234. case "browser_action_launch":
  235. case "command": // user can provide feedback to a tool or command use
  236. case "command_output": // user can send input to command stdin
  237. case "completion_result": // if this happens then the user has feedback for the completion result
  238. case "resume_task":
  239. case "resume_completed_task":
  240. case "mistake_limit_reached":
  241. vscode.postMessage({
  242. type: "askResponse",
  243. askResponse: "messageResponse",
  244. text,
  245. images,
  246. })
  247. break
  248. // there is no other case that a textfield should be enabled
  249. }
  250. }
  251. setInputValue("")
  252. setTextAreaDisabled(true)
  253. setSelectedImages([])
  254. setClineAsk(undefined)
  255. setEnableButtons(false)
  256. // setPrimaryButtonText(undefined)
  257. // setSecondaryButtonText(undefined)
  258. disableAutoScrollRef.current = false
  259. }
  260. },
  261. [messages.length, clineAsk]
  262. )
  263. const startNewTask = useCallback(() => {
  264. vscode.postMessage({ type: "clearTask" })
  265. }, [])
  266. /*
  267. 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.
  268. */
  269. const handlePrimaryButtonClick = useCallback(() => {
  270. switch (clineAsk) {
  271. case "api_req_failed":
  272. case "command":
  273. case "command_output":
  274. case "tool":
  275. case "browser_action_launch":
  276. case "resume_task":
  277. case "mistake_limit_reached":
  278. vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
  279. break
  280. case "completion_result":
  281. case "resume_completed_task":
  282. // extension waiting for feedback. but we can just present a new task button
  283. startNewTask()
  284. break
  285. }
  286. setTextAreaDisabled(true)
  287. setClineAsk(undefined)
  288. setEnableButtons(false)
  289. // setPrimaryButtonText(undefined)
  290. // setSecondaryButtonText(undefined)
  291. disableAutoScrollRef.current = false
  292. }, [clineAsk, startNewTask])
  293. const handleSecondaryButtonClick = useCallback(() => {
  294. if (isStreaming) {
  295. vscode.postMessage({ type: "cancelTask" })
  296. setDidClickCancel(true)
  297. return
  298. }
  299. switch (clineAsk) {
  300. case "api_req_failed":
  301. case "mistake_limit_reached":
  302. startNewTask()
  303. break
  304. case "command":
  305. case "tool":
  306. case "browser_action_launch":
  307. // responds to the API with a "This operation failed" and lets it try again
  308. vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" })
  309. break
  310. }
  311. setTextAreaDisabled(true)
  312. setClineAsk(undefined)
  313. setEnableButtons(false)
  314. // setPrimaryButtonText(undefined)
  315. // setSecondaryButtonText(undefined)
  316. disableAutoScrollRef.current = false
  317. }, [clineAsk, startNewTask, isStreaming])
  318. const handleTaskCloseButtonClick = useCallback(() => {
  319. startNewTask()
  320. }, [startNewTask])
  321. const { selectedModelInfo } = useMemo(() => {
  322. return normalizeApiConfiguration(apiConfiguration)
  323. }, [apiConfiguration])
  324. const selectImages = useCallback(() => {
  325. vscode.postMessage({ type: "selectImages" })
  326. }, [])
  327. const shouldDisableImages =
  328. !selectedModelInfo.supportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
  329. const handleMessage = useCallback(
  330. (e: MessageEvent) => {
  331. const message: ExtensionMessage = e.data
  332. switch (message.type) {
  333. case "action":
  334. switch (message.action!) {
  335. case "didBecomeVisible":
  336. if (!isHidden && !textAreaDisabled && !enableButtons) {
  337. textAreaRef.current?.focus()
  338. }
  339. break
  340. }
  341. break
  342. case "selectedImages":
  343. const newImages = message.images ?? []
  344. if (newImages.length > 0) {
  345. setSelectedImages((prevImages) =>
  346. [...prevImages, ...newImages].slice(0, MAX_IMAGES_PER_MESSAGE)
  347. )
  348. }
  349. break
  350. case "invoke":
  351. switch (message.invoke!) {
  352. case "sendMessage":
  353. handleSendMessage(message.text ?? "", message.images ?? [])
  354. break
  355. case "primaryButtonClick":
  356. handlePrimaryButtonClick()
  357. break
  358. case "secondaryButtonClick":
  359. handleSecondaryButtonClick()
  360. break
  361. }
  362. }
  363. // textAreaRef.current is not explicitly required here since react gaurantees that ref will be stable across re-renders, and we're not using its value but its reference.
  364. },
  365. [
  366. isHidden,
  367. textAreaDisabled,
  368. enableButtons,
  369. handleSendMessage,
  370. handlePrimaryButtonClick,
  371. handleSecondaryButtonClick,
  372. ]
  373. )
  374. useEvent("message", handleMessage)
  375. useMount(() => {
  376. // NOTE: the vscode window needs to be focused for this to work
  377. textAreaRef.current?.focus()
  378. })
  379. useEffect(() => {
  380. const timer = setTimeout(() => {
  381. if (!isHidden && !textAreaDisabled && !enableButtons) {
  382. textAreaRef.current?.focus()
  383. }
  384. }, 50)
  385. return () => {
  386. clearTimeout(timer)
  387. }
  388. }, [isHidden, textAreaDisabled, enableButtons])
  389. const visibleMessages = useMemo(() => {
  390. return modifiedMessages.filter((message) => {
  391. switch (message.ask) {
  392. case "completion_result":
  393. // don't show a chat row for a completion_result ask without text. This specific type of message only occurs if cline wants to execute a command as part of its completion result, in which case we interject the completion_result tool with the execute_command tool.
  394. if (message.text === "") {
  395. return false
  396. }
  397. break
  398. case "api_req_failed": // this message is used to update the latest api_req_started that the request failed
  399. case "resume_task":
  400. case "resume_completed_task":
  401. return false
  402. }
  403. switch (message.say) {
  404. case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
  405. case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried
  406. return false
  407. case "text":
  408. // Sometimes cline returns an empty text message, we don't want to render these. (We also use a say text for user messages, so in case they just sent images we still render that)
  409. if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
  410. return false
  411. }
  412. break
  413. // case "inspect_site_result":
  414. // // don't show row for inspect site result until a screenshot is captured
  415. // return !!message.images
  416. }
  417. return true
  418. })
  419. }, [modifiedMessages])
  420. const isBrowserSessionMessage = (message: ClineMessage): boolean => {
  421. // which of visible messages are browser session messages, see above
  422. if (message.type === "ask") {
  423. return ["browser_action_launch"].includes(message.ask!)
  424. }
  425. if (message.type === "say") {
  426. return ["api_req_started", "text", "browser_action", "browser_action_result"].includes(message.say!)
  427. }
  428. return false
  429. }
  430. const groupedMessages = useMemo(() => {
  431. const result: (ClineMessage | ClineMessage[])[] = []
  432. let currentGroup: ClineMessage[] = []
  433. let isInBrowserSession = false
  434. const endBrowserSession = () => {
  435. if (currentGroup.length > 0) {
  436. result.push([...currentGroup])
  437. currentGroup = []
  438. isInBrowserSession = false
  439. }
  440. }
  441. visibleMessages.forEach((message) => {
  442. if (message.ask === "browser_action_launch") {
  443. // complete existing browser session if any
  444. endBrowserSession()
  445. // start new
  446. isInBrowserSession = true
  447. currentGroup.push(message)
  448. } else if (isInBrowserSession) {
  449. if (isBrowserSessionMessage(message)) {
  450. currentGroup.push(message)
  451. // Check if this is a close action
  452. if (message.say === "browser_action") {
  453. const browserAction = JSON.parse(message.text || "{}") as ClineSayBrowserAction
  454. if (browserAction.action === "close") {
  455. endBrowserSession()
  456. }
  457. }
  458. } else {
  459. // complete existing browser session if any
  460. endBrowserSession()
  461. result.push(message)
  462. }
  463. } else {
  464. result.push(message)
  465. }
  466. })
  467. // Handle case where browser session is the last group
  468. if (currentGroup.length > 0) {
  469. result.push([...currentGroup])
  470. }
  471. return result
  472. }, [visibleMessages])
  473. // scrolling
  474. const scrollToBottomSmooth = useMemo(
  475. () =>
  476. debounce(
  477. () => {
  478. virtuosoRef.current?.scrollTo({
  479. top: Number.MAX_SAFE_INTEGER,
  480. behavior: "smooth",
  481. })
  482. },
  483. 10,
  484. { immediate: true }
  485. ),
  486. []
  487. )
  488. const scrollToBottomAuto = useCallback(() => {
  489. virtuosoRef.current?.scrollTo({
  490. top: Number.MAX_SAFE_INTEGER,
  491. behavior: "auto", // instant causes crash
  492. })
  493. }, [])
  494. // scroll when user toggles certain rows
  495. const toggleRowExpansion = useCallback(
  496. (ts: number) => {
  497. const isCollapsing = expandedRows[ts] ?? false
  498. const lastGroup = groupedMessages.at(-1)
  499. const isLast = Array.isArray(lastGroup) ? lastGroup[0].ts === ts : lastGroup?.ts === ts
  500. const secondToLastGroup = groupedMessages.at(-2)
  501. const isSecondToLast = Array.isArray(secondToLastGroup)
  502. ? secondToLastGroup[0].ts === ts
  503. : secondToLastGroup?.ts === ts
  504. const isLastCollapsedApiReq =
  505. isLast &&
  506. !Array.isArray(lastGroup) && // Make sure it's not a browser session group
  507. lastGroup?.say === "api_req_started" &&
  508. !expandedRows[lastGroup.ts]
  509. setExpandedRows((prev) => ({
  510. ...prev,
  511. [ts]: !prev[ts],
  512. }))
  513. // disable auto scroll when user expands row
  514. if (!isCollapsing) {
  515. disableAutoScrollRef.current = true
  516. }
  517. if (isCollapsing && isAtBottom) {
  518. const timer = setTimeout(() => {
  519. scrollToBottomAuto()
  520. }, 0)
  521. return () => clearTimeout(timer)
  522. } else if (isLast || isSecondToLast) {
  523. if (isCollapsing) {
  524. if (isSecondToLast && !isLastCollapsedApiReq) {
  525. return
  526. }
  527. const timer = setTimeout(() => {
  528. scrollToBottomAuto()
  529. }, 0)
  530. return () => clearTimeout(timer)
  531. } else {
  532. const timer = setTimeout(() => {
  533. virtuosoRef.current?.scrollToIndex({
  534. index: groupedMessages.length - (isLast ? 1 : 2),
  535. align: "start",
  536. })
  537. }, 0)
  538. return () => clearTimeout(timer)
  539. }
  540. }
  541. },
  542. [groupedMessages, expandedRows, scrollToBottomAuto, isAtBottom]
  543. )
  544. const handleRowHeightChange = useCallback(
  545. (isTaller: boolean) => {
  546. if (!disableAutoScrollRef.current) {
  547. if (isTaller) {
  548. scrollToBottomSmooth()
  549. } else {
  550. setTimeout(() => {
  551. scrollToBottomAuto()
  552. }, 0)
  553. }
  554. }
  555. },
  556. [scrollToBottomSmooth, scrollToBottomAuto]
  557. )
  558. useEffect(() => {
  559. if (!disableAutoScrollRef.current) {
  560. setTimeout(() => {
  561. scrollToBottomSmooth()
  562. }, 50)
  563. // return () => clearTimeout(timer) // dont cleanup since if visibleMessages.length changes it cancels.
  564. }
  565. }, [visibleMessages.length, scrollToBottomSmooth])
  566. const handleWheel = useCallback((event: Event) => {
  567. const wheelEvent = event as WheelEvent
  568. if (wheelEvent.deltaY && wheelEvent.deltaY < 0) {
  569. if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
  570. // user scrolled up
  571. disableAutoScrollRef.current = true
  572. }
  573. }
  574. }, [])
  575. useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance
  576. const placeholderText = useMemo(() => {
  577. const text = task ? "Type a message (@ to add context)..." : "Type your task here (@ to add context)..."
  578. return text
  579. }, [task])
  580. const itemContent = useCallback(
  581. (index: number, messageOrGroup: ClineMessage | ClineMessage[]) => {
  582. // browser session group
  583. if (Array.isArray(messageOrGroup)) {
  584. const firstMessage = messageOrGroup[0]
  585. return (
  586. <BrowserSessionRow
  587. key={firstMessage.ts}
  588. messages={messageOrGroup}
  589. isExpanded={expandedRows[firstMessage.ts] || false}
  590. onToggleExpand={() => toggleRowExpansion(firstMessage.ts)}
  591. lastModifiedMessage={modifiedMessages.at(-1)}
  592. isLast={index === groupedMessages.length - 1}
  593. onHeightChange={handleRowHeightChange}
  594. />
  595. )
  596. }
  597. // regular message
  598. return (
  599. <ChatRow
  600. key={messageOrGroup.ts}
  601. message={messageOrGroup}
  602. isExpanded={expandedRows[messageOrGroup.ts] || false}
  603. onToggleExpand={() => toggleRowExpansion(messageOrGroup.ts)}
  604. lastModifiedMessage={modifiedMessages.at(-1)}
  605. isLast={index === groupedMessages.length - 1}
  606. onHeightChange={handleRowHeightChange}
  607. />
  608. )
  609. },
  610. [expandedRows, modifiedMessages, groupedMessages.length, toggleRowExpansion, handleRowHeightChange]
  611. )
  612. return (
  613. <div
  614. style={{
  615. position: "fixed",
  616. top: 0,
  617. left: 0,
  618. right: 0,
  619. bottom: 0,
  620. display: isHidden ? "none" : "flex",
  621. flexDirection: "column",
  622. overflow: "hidden",
  623. }}>
  624. {task ? (
  625. <TaskHeader
  626. task={task}
  627. tokensIn={apiMetrics.totalTokensIn}
  628. tokensOut={apiMetrics.totalTokensOut}
  629. doesModelSupportPromptCache={selectedModelInfo.supportsPromptCache}
  630. cacheWrites={apiMetrics.totalCacheWrites}
  631. cacheReads={apiMetrics.totalCacheReads}
  632. totalCost={apiMetrics.totalCost}
  633. onClose={handleTaskCloseButtonClick}
  634. />
  635. ) : (
  636. <div
  637. style={{
  638. flexGrow: 1,
  639. overflowY: "auto",
  640. display: "flex",
  641. flexDirection: "column",
  642. }}>
  643. {showAnnouncement && <Announcement version={version} hideAnnouncement={hideAnnouncement} />}
  644. <div style={{ padding: "0 20px", flexShrink: 0 }}>
  645. <h2>What can I do for you?</h2>
  646. <p>
  647. Thanks to{" "}
  648. <VSCodeLink
  649. href="https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf"
  650. style={{ display: "inline" }}>
  651. Claude 3.5 Sonnet's agentic coding capabilities,
  652. </VSCodeLink>{" "}
  653. I can handle complex software development tasks step-by-step. With tools that let me create
  654. & edit files, explore complex projects, and execute terminal commands (after you grant
  655. permission), I can assist you in ways that go beyond code completion or tech support.
  656. </p>
  657. </div>
  658. {taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
  659. </div>
  660. )}
  661. {task && (
  662. <>
  663. <div style={{ flexGrow: 1, display: "flex" }} ref={scrollContainerRef}>
  664. <Virtuoso
  665. ref={virtuosoRef}
  666. key={task.ts} // trick to make sure virtuoso re-renders when task changes, and we use initialTopMostItemIndex to start at the bottom
  667. className="scrollable"
  668. style={{
  669. flexGrow: 1,
  670. overflowY: "scroll", // always show scrollbar
  671. }}
  672. components={{
  673. Footer: () => <div style={{ height: 5 }} />, // Add empty padding at the bottom
  674. }}
  675. // increasing top by 3_000 to prevent jumping around when user collapses a row
  676. increaseViewportBy={{ top: 3_000, bottom: Number.MAX_SAFE_INTEGER }} // hack to make sure the last message is always rendered to get truly perfect scroll to bottom animation when new messages are added (Number.MAX_SAFE_INTEGER is safe for arithmetic operations, which is all virtuoso uses this value for in src/sizeRangeSystem.ts)
  677. data={groupedMessages} // messages is the raw format returned by extension, modifiedMessages is the manipulated structure that combines certain messages of related type, and visibleMessages is the filtered structure that removes messages that should not be rendered
  678. itemContent={itemContent}
  679. atBottomStateChange={(isAtBottom) => {
  680. setIsAtBottom(isAtBottom)
  681. if (isAtBottom) {
  682. disableAutoScrollRef.current = false
  683. }
  684. setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom)
  685. }}
  686. atBottomThreshold={10} // anything lower causes issues with followOutput
  687. initialTopMostItemIndex={groupedMessages.length - 1}
  688. />
  689. </div>
  690. {showScrollToBottom ? (
  691. <div
  692. style={{
  693. display: "flex",
  694. padding: "10px 15px 0px 15px",
  695. }}>
  696. <ScrollToBottomButton
  697. onClick={() => {
  698. scrollToBottomSmooth()
  699. disableAutoScrollRef.current = false
  700. }}>
  701. <span className="codicon codicon-chevron-down" style={{ fontSize: "18px" }}></span>
  702. </ScrollToBottomButton>
  703. </div>
  704. ) : (
  705. <div
  706. style={{
  707. opacity:
  708. primaryButtonText || secondaryButtonText || isStreaming
  709. ? enableButtons || (isStreaming && !didClickCancel)
  710. ? 1
  711. : 0.5
  712. : 0,
  713. display: "flex",
  714. padding: "10px 15px 0px 15px",
  715. }}>
  716. {primaryButtonText && !isStreaming && (
  717. <VSCodeButton
  718. appearance="primary"
  719. disabled={!enableButtons}
  720. style={{
  721. flex: secondaryButtonText ? 1 : 2,
  722. marginRight: secondaryButtonText ? "6px" : "0",
  723. }}
  724. onClick={handlePrimaryButtonClick}>
  725. {primaryButtonText}
  726. </VSCodeButton>
  727. )}
  728. {(secondaryButtonText || isStreaming) && (
  729. <VSCodeButton
  730. appearance="secondary"
  731. disabled={!enableButtons && !(isStreaming && !didClickCancel)}
  732. style={{
  733. flex: isStreaming ? 2 : 1,
  734. marginLeft: isStreaming ? 0 : "6px",
  735. }}
  736. onClick={handleSecondaryButtonClick}>
  737. {isStreaming ? "Cancel" : secondaryButtonText}
  738. </VSCodeButton>
  739. )}
  740. </div>
  741. )}
  742. </>
  743. )}
  744. <ChatTextArea
  745. ref={textAreaRef}
  746. inputValue={inputValue}
  747. setInputValue={setInputValue}
  748. textAreaDisabled={textAreaDisabled}
  749. placeholderText={placeholderText}
  750. selectedImages={selectedImages}
  751. setSelectedImages={setSelectedImages}
  752. onSend={() => handleSendMessage(inputValue, selectedImages)}
  753. onSelectImages={selectImages}
  754. shouldDisableImages={shouldDisableImages}
  755. onHeightChange={() => {
  756. if (isAtBottom) {
  757. scrollToBottomAuto()
  758. }
  759. }}
  760. />
  761. </div>
  762. )
  763. }
  764. const ScrollToBottomButton = styled.div`
  765. background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent);
  766. border-radius: 3px;
  767. overflow: hidden;
  768. cursor: pointer;
  769. display: flex;
  770. justify-content: center;
  771. align-items: center;
  772. flex: 1;
  773. height: 25px;
  774. &:hover {
  775. background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 90%, transparent);
  776. }
  777. &:active {
  778. background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 70%, transparent);
  779. }
  780. `
  781. export default ChatView