ChatView.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. import { ClaudeAsk, ClaudeMessage, ExtensionMessage } from "@shared/ExtensionMessage"
  2. import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
  3. import { KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
  4. import vsDarkPlus from "react-syntax-highlighter/dist/esm/styles/prism/vsc-dark-plus"
  5. import DynamicTextArea from "react-textarea-autosize"
  6. import { useEvent, useMount } from "react-use"
  7. import { combineApiRequests } from "../utils/combineApiRequests"
  8. import { combineCommandSequences } from "../utils/combineCommandSequences"
  9. import { getApiMetrics } from "../utils/getApiMetrics"
  10. import { getSyntaxHighlighterStyleFromTheme } from "../utils/getSyntaxHighlighterStyleFromTheme"
  11. import { vscode } from "../utils/vscode"
  12. import ChatRow from "./ChatRow"
  13. import TaskHeader from "./TaskHeader"
  14. import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
  15. import Announcement from "./Announcement"
  16. interface ChatViewProps {
  17. messages: ClaudeMessage[]
  18. isHidden: boolean
  19. vscodeThemeName?: string
  20. showAnnouncement: boolean
  21. hideAnnouncement: () => void
  22. }
  23. // maybe instead of storing state in App, just make chatview always show so dont conditionally load/unload? need to make sure messages are persisted (i remember seeing something about how webviews can be frozen in docs)
  24. const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideAnnouncement }: ChatViewProps) => {
  25. //const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined
  26. const task = messages.length > 0 ? messages[0] : undefined // 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 ClaudeDev.abort)
  27. const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
  28. // has to be after api_req_finished are all reduced into api_req_started messages
  29. const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
  30. const [inputValue, setInputValue] = useState("")
  31. const textAreaRef = useRef<HTMLTextAreaElement>(null)
  32. const [textAreaDisabled, setTextAreaDisabled] = useState(false)
  33. // 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)
  34. const [claudeAsk, setClaudeAsk] = useState<ClaudeAsk | undefined>(undefined)
  35. const [enableButtons, setEnableButtons] = useState<boolean>(false)
  36. const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
  37. const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
  38. const [syntaxHighlighterStyle, setSyntaxHighlighterStyle] = useState(vsDarkPlus)
  39. const virtuosoRef = useRef<VirtuosoHandle>(null)
  40. const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
  41. const toggleRowExpansion = (ts: number) => {
  42. setExpandedRows((prev) => ({
  43. ...prev,
  44. [ts]: !prev[ts],
  45. }))
  46. }
  47. useEffect(() => {
  48. if (!vscodeThemeName) return
  49. const theme = getSyntaxHighlighterStyleFromTheme(vscodeThemeName)
  50. if (theme) {
  51. setSyntaxHighlighterStyle(theme)
  52. }
  53. }, [vscodeThemeName])
  54. useEffect(() => {
  55. // if last message is an ask, show user ask UI
  56. // 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.
  57. // basically as long as a task is active, the conversation history will be persisted
  58. const lastMessage = messages.at(-1)
  59. if (lastMessage) {
  60. switch (lastMessage.type) {
  61. case "ask":
  62. switch (lastMessage.ask) {
  63. case "request_limit_reached":
  64. setTextAreaDisabled(true)
  65. setClaudeAsk("request_limit_reached")
  66. setEnableButtons(true)
  67. setPrimaryButtonText("Proceed")
  68. setSecondaryButtonText("Start New Task")
  69. break
  70. case "api_req_failed":
  71. setTextAreaDisabled(true)
  72. setClaudeAsk("api_req_failed")
  73. setEnableButtons(true)
  74. setPrimaryButtonText("Retry")
  75. setSecondaryButtonText("Start New Task")
  76. break
  77. case "followup":
  78. setTextAreaDisabled(false)
  79. setClaudeAsk("followup")
  80. setEnableButtons(false)
  81. // setPrimaryButtonText(undefined)
  82. // setSecondaryButtonText(undefined)
  83. break
  84. case "tool":
  85. setTextAreaDisabled(false)
  86. setClaudeAsk("tool")
  87. setEnableButtons(true)
  88. setPrimaryButtonText("Approve")
  89. setSecondaryButtonText("Reject")
  90. break
  91. case "command":
  92. setTextAreaDisabled(false)
  93. setClaudeAsk("command")
  94. setEnableButtons(true)
  95. setPrimaryButtonText("Run Command")
  96. setSecondaryButtonText("Reject")
  97. break
  98. case "command_output":
  99. setTextAreaDisabled(false)
  100. setClaudeAsk("command_output")
  101. setEnableButtons(true)
  102. setPrimaryButtonText("Exit Command")
  103. setSecondaryButtonText(undefined)
  104. break
  105. case "completion_result":
  106. // extension waiting for feedback. but we can just present a new task button
  107. setTextAreaDisabled(false)
  108. setClaudeAsk("completion_result")
  109. setEnableButtons(true)
  110. setPrimaryButtonText("Start New Task")
  111. setSecondaryButtonText(undefined)
  112. break
  113. }
  114. break
  115. case "say":
  116. // don't want to reset since there could be a "say" after an "ask" while ask is waiting for response
  117. switch (lastMessage.say) {
  118. case "task":
  119. break
  120. case "error":
  121. break
  122. case "api_req_started":
  123. if (messages.at(-2)?.ask === "command_output") {
  124. // 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)
  125. setInputValue("")
  126. setTextAreaDisabled(true)
  127. setClaudeAsk(undefined)
  128. setEnableButtons(false)
  129. }
  130. break
  131. case "api_req_finished":
  132. break
  133. case "text":
  134. break
  135. case "command_output":
  136. break
  137. case "completion_result":
  138. break
  139. }
  140. break
  141. }
  142. } else {
  143. // this would get called after sending the first message, so we have to watch messages.length instead
  144. // No messages, so user has to submit a task
  145. // setTextAreaDisabled(false)
  146. // setClaudeAsk(undefined)
  147. // setPrimaryButtonText(undefined)
  148. // setSecondaryButtonText(undefined)
  149. }
  150. }, [messages])
  151. useEffect(() => {
  152. if (messages.length === 0) {
  153. setTextAreaDisabled(false)
  154. setClaudeAsk(undefined)
  155. setEnableButtons(false)
  156. setPrimaryButtonText(undefined)
  157. setSecondaryButtonText(undefined)
  158. }
  159. }, [messages.length])
  160. const handleSendMessage = () => {
  161. const text = inputValue.trim()
  162. if (text) {
  163. if (messages.length === 0) {
  164. vscode.postMessage({ type: "newTask", text })
  165. } else if (claudeAsk) {
  166. switch (claudeAsk) {
  167. case "followup":
  168. case "tool":
  169. case "command": // user can provide feedback to a tool or command use
  170. case "command_output": // user can send input to command stdin
  171. case "completion_result": // if this happens then the user has feedback for the completion result
  172. vscode.postMessage({ type: "askResponse", askResponse: "textResponse", text })
  173. break
  174. // there is no other case that a textfield should be enabled
  175. }
  176. }
  177. setInputValue("")
  178. setTextAreaDisabled(true)
  179. setClaudeAsk(undefined)
  180. setEnableButtons(false)
  181. // setPrimaryButtonText(undefined)
  182. // setSecondaryButtonText(undefined)
  183. }
  184. }
  185. /*
  186. This logic depends on the useEffect[messages] above to set claudeAsk, after which buttons are shown and we then send an askResponse to the extension.
  187. */
  188. const handlePrimaryButtonClick = () => {
  189. switch (claudeAsk) {
  190. case "request_limit_reached":
  191. case "api_req_failed":
  192. case "command":
  193. case "command_output":
  194. case "tool":
  195. vscode.postMessage({ type: "askResponse", askResponse: "yesButtonTapped" })
  196. break
  197. case "completion_result":
  198. // extension waiting for feedback. but we can just present a new task button
  199. startNewTask()
  200. break
  201. }
  202. setTextAreaDisabled(true)
  203. setClaudeAsk(undefined)
  204. setEnableButtons(false)
  205. // setPrimaryButtonText(undefined)
  206. // setSecondaryButtonText(undefined)
  207. }
  208. const handleSecondaryButtonClick = () => {
  209. switch (claudeAsk) {
  210. case "request_limit_reached":
  211. case "api_req_failed":
  212. startNewTask()
  213. break
  214. case "command":
  215. case "tool":
  216. // responds to the API with a "This operation failed" and lets it try again
  217. vscode.postMessage({ type: "askResponse", askResponse: "noButtonTapped" })
  218. break
  219. }
  220. setTextAreaDisabled(true)
  221. setClaudeAsk(undefined)
  222. setEnableButtons(false)
  223. // setPrimaryButtonText(undefined)
  224. // setSecondaryButtonText(undefined)
  225. }
  226. const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
  227. if (event.key === "Enter" && !event.shiftKey) {
  228. event.preventDefault()
  229. handleSendMessage()
  230. }
  231. }
  232. const handleTaskCloseButtonClick = () => {
  233. startNewTask()
  234. }
  235. const startNewTask = () => {
  236. vscode.postMessage({ type: "clearTask" })
  237. }
  238. const handleMessage = useCallback(
  239. (e: MessageEvent) => {
  240. const message: ExtensionMessage = e.data
  241. switch (message.type) {
  242. case "action":
  243. switch (message.action!) {
  244. case "didBecomeVisible":
  245. if (!isHidden && !textAreaDisabled && !enableButtons) {
  246. textAreaRef.current?.focus()
  247. }
  248. break
  249. }
  250. break
  251. }
  252. // 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.
  253. },
  254. [isHidden, textAreaDisabled, enableButtons]
  255. )
  256. useEvent("message", handleMessage)
  257. useMount(() => {
  258. // NOTE: the vscode window needs to be focused for this to work
  259. textAreaRef.current?.focus()
  260. })
  261. useEffect(() => {
  262. const timer = setTimeout(() => {
  263. if (!isHidden && !textAreaDisabled && !enableButtons) {
  264. textAreaRef.current?.focus()
  265. }
  266. }, 50)
  267. return () => {
  268. clearTimeout(timer)
  269. }
  270. }, [isHidden, textAreaDisabled, enableButtons])
  271. const visibleMessages = useMemo(() => {
  272. return modifiedMessages.filter((message) => {
  273. switch (message.ask) {
  274. case "completion_result":
  275. // don't show a chat row for a completion_result ask without text. This specific type of message only occurs if Claude 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.
  276. if (message.text === "") {
  277. return false
  278. }
  279. break
  280. case "api_req_failed": // this message is used to update the latest api_req_started that the request failed
  281. return false
  282. }
  283. switch (message.say) {
  284. case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
  285. case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried
  286. return false
  287. }
  288. return true
  289. })
  290. }, [modifiedMessages])
  291. useEffect(() => {
  292. // We use a setTimeout to ensure new content is rendered before scrolling to the bottom. virtuoso's followOutput would scroll to the bottom before the new content could render.
  293. const timer = setTimeout(() => {
  294. // TODO: we can use virtuoso's isAtBottom to prevent scrolling if user is scrolled up, and show a 'scroll to bottom' button for better UX
  295. // NOTE: scroll to bottom may not work if you use margin, see virtuoso's troubleshooting
  296. virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" })
  297. }, 50)
  298. return () => clearTimeout(timer)
  299. }, [visibleMessages])
  300. const placeholderText = useMemo(() => {
  301. if (messages.at(-1)?.ask === "command_output") {
  302. return "Type input to command stdin..."
  303. }
  304. return task ? "Type a message..." : "Type your task here..."
  305. }, [task, messages])
  306. return (
  307. <div
  308. style={{
  309. position: "fixed",
  310. top: 0,
  311. left: 0,
  312. right: 0,
  313. bottom: 0,
  314. display: isHidden ? "none" : "flex",
  315. flexDirection: "column",
  316. overflow: "hidden",
  317. }}>
  318. {task ? (
  319. <TaskHeader
  320. taskText={task.text || ""}
  321. tokensIn={apiMetrics.totalTokensIn}
  322. tokensOut={apiMetrics.totalTokensOut}
  323. totalCost={apiMetrics.totalCost}
  324. onClose={handleTaskCloseButtonClick}
  325. isHidden={isHidden}
  326. />
  327. ) : (
  328. <>
  329. {showAnnouncement && <Announcement hideAnnouncement={hideAnnouncement} />}
  330. <div style={{ padding: "0 20px" }}>
  331. <h2>What can I do for you?</h2>
  332. <p>
  333. Thanks to{" "}
  334. <VSCodeLink
  335. href="https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf"
  336. style={{ display: "inline" }}>
  337. Claude 3.5 Sonnet's agentic coding capabilities,
  338. </VSCodeLink>{" "}
  339. I can handle complex software development tasks step-by-step. With tools that let me create
  340. & edit files, analyze project source code, and execute terminal commands (after you grant
  341. permission), I can assist you in ways that go beyond simple code completion or tech support.
  342. </p>
  343. </div>
  344. </>
  345. )}
  346. <Virtuoso
  347. ref={virtuosoRef}
  348. className="scrollable"
  349. style={{
  350. flexGrow: 1,
  351. overflowY: "scroll", // always show scrollbar
  352. }}
  353. // followOutput={(isAtBottom) => {
  354. // const lastMessage = modifiedMessages.at(-1)
  355. // if (lastMessage && shouldShowChatRow(lastMessage)) {
  356. // return "smooth"
  357. // }
  358. // return false
  359. // }}
  360. increaseViewportBy={{ top: 0, 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)
  361. data={visibleMessages} // 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
  362. itemContent={(index, message) => (
  363. <ChatRow
  364. key={message.ts}
  365. message={message}
  366. syntaxHighlighterStyle={syntaxHighlighterStyle}
  367. isExpanded={expandedRows[message.ts] || false}
  368. onToggleExpand={() => toggleRowExpansion(message.ts)}
  369. lastModifiedMessage={modifiedMessages.at(-1)}
  370. isLast={index === visibleMessages.length - 1}
  371. />
  372. )}
  373. />
  374. <div
  375. style={{
  376. opacity: primaryButtonText || secondaryButtonText ? (enableButtons ? 1 : 0.5) : 0,
  377. display: "flex",
  378. padding: "10px 15px 0px 15px",
  379. }}>
  380. {primaryButtonText && (
  381. <VSCodeButton
  382. appearance="primary"
  383. disabled={!enableButtons}
  384. style={{
  385. flex: secondaryButtonText ? 1 : 2,
  386. marginRight: secondaryButtonText ? "6px" : "0",
  387. }}
  388. onClick={handlePrimaryButtonClick}>
  389. {primaryButtonText}
  390. </VSCodeButton>
  391. )}
  392. {secondaryButtonText && (
  393. <VSCodeButton
  394. appearance="secondary"
  395. disabled={!enableButtons}
  396. style={{ flex: 1, marginLeft: "6px" }}
  397. onClick={handleSecondaryButtonClick}>
  398. {secondaryButtonText}
  399. </VSCodeButton>
  400. )}
  401. </div>
  402. <div style={{ padding: "10px 15px", opacity: textAreaDisabled ? 0.5 : 1, position: "relative" }}>
  403. <DynamicTextArea
  404. ref={textAreaRef}
  405. value={inputValue}
  406. disabled={textAreaDisabled}
  407. onChange={(e) => setInputValue(e.target.value)}
  408. onKeyDown={handleKeyDown}
  409. onHeightChange={() =>
  410. //virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "auto" })
  411. virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" })
  412. }
  413. placeholder={placeholderText}
  414. maxRows={10}
  415. autoFocus={true}
  416. style={{
  417. width: "100%",
  418. boxSizing: "border-box",
  419. backgroundColor: "var(--vscode-input-background)",
  420. color: "var(--vscode-input-foreground)",
  421. border: "1px solid var(--vscode-input-border)",
  422. borderRadius: 2,
  423. fontFamily: "var(--vscode-font-family)",
  424. fontSize: "var(--vscode-editor-font-size)",
  425. lineHeight: "var(--vscode-editor-line-height)",
  426. resize: "none",
  427. overflow: "hidden",
  428. padding: "8px 36px 8px 8px",
  429. cursor: textAreaDisabled ? "not-allowed" : undefined,
  430. }}
  431. />
  432. <div
  433. style={{
  434. position: "absolute",
  435. right: 20,
  436. display: "flex",
  437. alignItems: "center",
  438. top: 0,
  439. bottom: 1.5,
  440. }}>
  441. <VSCodeButton
  442. disabled={textAreaDisabled}
  443. appearance="icon"
  444. aria-label="Send Message"
  445. onClick={handleSendMessage}>
  446. <span className="codicon codicon-send"></span>
  447. </VSCodeButton>
  448. </div>
  449. </div>
  450. </div>
  451. )
  452. }
  453. export default ChatView