ChatView.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  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 { animateScroll as scroll } from "react-scroll"
  5. import DynamicTextArea from "react-textarea-autosize"
  6. import { combineApiRequests } from "../utilities/combineApiRequests"
  7. import { combineCommandSequences } from "../utilities/combineCommandSequences"
  8. import { getApiMetrics } from "../utilities/getApiMetrics"
  9. import { vscode } from "../utilities/vscode"
  10. import ChatRow from "./ChatRow"
  11. import TaskHeader from "./TaskHeader"
  12. import { getSyntaxHighlighterStyleFromTheme } from "../utilities/getSyntaxHighlighterStyleFromTheme"
  13. import vsDarkPlus from "react-syntax-highlighter/dist/esm/styles/prism/vsc-dark-plus"
  14. import { useEvent } from "react-use"
  15. interface ChatViewProps {
  16. messages: ClaudeMessage[]
  17. isHidden: boolean
  18. vscodeThemeName?: string
  19. }
  20. // 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)
  21. const ChatView = ({ messages, isHidden, vscodeThemeName }: ChatViewProps) => {
  22. //const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined
  23. 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)
  24. const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
  25. // has to be after api_req_finished are all reduced into api_req_started messages
  26. const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
  27. const [inputValue, setInputValue] = useState("")
  28. const textAreaRef = useRef<HTMLTextAreaElement>(null)
  29. const [textAreaHeight, setTextAreaHeight] = useState<number | undefined>(undefined)
  30. const [textAreaDisabled, setTextAreaDisabled] = useState(false)
  31. // 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)
  32. const [claudeAsk, setClaudeAsk] = useState<ClaudeAsk | undefined>(undefined)
  33. const [enableButtons, setEnableButtons] = useState<boolean>(false)
  34. const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
  35. const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
  36. const [syntaxHighlighterStyle, setSyntaxHighlighterStyle] = useState(vsDarkPlus)
  37. useEffect(() => {
  38. if (!vscodeThemeName) return
  39. const theme = getSyntaxHighlighterStyleFromTheme(vscodeThemeName)
  40. if (theme) {
  41. setSyntaxHighlighterStyle(theme)
  42. }
  43. }, [vscodeThemeName])
  44. const scrollToBottom = (instant: boolean = false) => {
  45. const options = {
  46. containerId: "chat-view-container",
  47. duration: instant ? 0 : 500,
  48. smooth: instant ? false : "easeOutQuint",
  49. }
  50. scroll.scrollToBottom(options)
  51. }
  52. // scroll to bottom when new message is added
  53. const visibleMessages = useMemo(
  54. () =>
  55. modifiedMessages.filter(
  56. (message) => !(message.type === "ask" && message.ask === "completion_result" && message.text === "")
  57. ),
  58. [modifiedMessages]
  59. )
  60. useEffect(() => {
  61. const timer = setTimeout(() => {
  62. scrollToBottom()
  63. }, 0)
  64. return () => {
  65. clearTimeout(timer)
  66. }
  67. }, [visibleMessages])
  68. useEffect(() => {
  69. // if last message is an ask, show user ask UI
  70. // 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.
  71. // basically as long as a task is active, the conversation history will be persisted
  72. const lastMessage = messages.at(-1)
  73. if (lastMessage) {
  74. switch (lastMessage.type) {
  75. case "ask":
  76. switch (lastMessage.ask) {
  77. case "request_limit_reached":
  78. setTextAreaDisabled(true)
  79. setClaudeAsk("request_limit_reached")
  80. setEnableButtons(true)
  81. setPrimaryButtonText("Proceed")
  82. setSecondaryButtonText("Start New Task")
  83. break
  84. case "followup":
  85. setTextAreaDisabled(false)
  86. setClaudeAsk("followup")
  87. setEnableButtons(false)
  88. // setPrimaryButtonText(undefined)
  89. // setSecondaryButtonText(undefined)
  90. break
  91. case "tool":
  92. setTextAreaDisabled(false)
  93. setClaudeAsk("tool")
  94. setEnableButtons(true)
  95. setPrimaryButtonText("Approve")
  96. setSecondaryButtonText("Reject")
  97. break
  98. case "command":
  99. setTextAreaDisabled(false)
  100. setClaudeAsk("command")
  101. setEnableButtons(true)
  102. setPrimaryButtonText("Run Command")
  103. setSecondaryButtonText("Reject")
  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. break
  124. case "api_req_finished":
  125. break
  126. case "text":
  127. break
  128. case "command_output":
  129. break
  130. case "completion_result":
  131. break
  132. }
  133. break
  134. }
  135. } else {
  136. // this would get called after sending the first message, so we have to watch messages.length instead
  137. // No messages, so user has to submit a task
  138. // setTextAreaDisabled(false)
  139. // setClaudeAsk(undefined)
  140. // setPrimaryButtonText(undefined)
  141. // setSecondaryButtonText(undefined)
  142. }
  143. }, [messages])
  144. useEffect(() => {
  145. if (messages.length === 0) {
  146. setTextAreaDisabled(false)
  147. setClaudeAsk(undefined)
  148. setEnableButtons(false)
  149. setPrimaryButtonText(undefined)
  150. setSecondaryButtonText(undefined)
  151. }
  152. }, [messages.length])
  153. const handleSendMessage = () => {
  154. const text = inputValue.trim()
  155. if (text) {
  156. if (messages.length === 0) {
  157. vscode.postMessage({ type: "newTask", text })
  158. } else if (claudeAsk) {
  159. switch (claudeAsk) {
  160. case "followup":
  161. case "tool":
  162. case "command": // user can provide feedback to a tool or command use
  163. case "completion_result": // if this happens then the user has feedback for the completion result
  164. vscode.postMessage({ type: "askResponse", askResponse: "textResponse", text })
  165. break
  166. // there is no other case that a textfield should be enabled
  167. }
  168. }
  169. setInputValue("")
  170. setTextAreaDisabled(true)
  171. setClaudeAsk(undefined)
  172. setEnableButtons(false)
  173. // setPrimaryButtonText(undefined)
  174. // setSecondaryButtonText(undefined)
  175. }
  176. }
  177. /*
  178. 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.
  179. */
  180. const handlePrimaryButtonClick = () => {
  181. switch (claudeAsk) {
  182. case "request_limit_reached":
  183. case "command":
  184. case "tool":
  185. vscode.postMessage({ type: "askResponse", askResponse: "yesButtonTapped" })
  186. break
  187. case "completion_result":
  188. // extension waiting for feedback. but we can just present a new task button
  189. startNewTask()
  190. break
  191. }
  192. setTextAreaDisabled(true)
  193. setClaudeAsk(undefined)
  194. setEnableButtons(false)
  195. // setPrimaryButtonText(undefined)
  196. // setSecondaryButtonText(undefined)
  197. }
  198. const handleSecondaryButtonClick = () => {
  199. switch (claudeAsk) {
  200. case "request_limit_reached":
  201. startNewTask()
  202. break
  203. case "command":
  204. case "tool":
  205. // responds to the API with a "This operation failed" and lets it try again
  206. vscode.postMessage({ type: "askResponse", askResponse: "noButtonTapped" })
  207. break
  208. }
  209. setTextAreaDisabled(true)
  210. setClaudeAsk(undefined)
  211. setEnableButtons(false)
  212. // setPrimaryButtonText(undefined)
  213. // setSecondaryButtonText(undefined)
  214. }
  215. const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
  216. if (event.key === "Enter" && !event.shiftKey) {
  217. event.preventDefault()
  218. handleSendMessage()
  219. }
  220. }
  221. const handleTaskCloseButtonClick = () => {
  222. startNewTask()
  223. }
  224. const startNewTask = () => {
  225. vscode.postMessage({ type: "clearTask" })
  226. }
  227. const handleMessage = useCallback(
  228. (e: MessageEvent) => {
  229. const message: ExtensionMessage = e.data
  230. switch (message.type) {
  231. case "action":
  232. switch (message.action!) {
  233. case "didBecomeVisible":
  234. if (!isHidden && !textAreaDisabled && !enableButtons) {
  235. textAreaRef.current?.focus()
  236. }
  237. break
  238. }
  239. break
  240. }
  241. // 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.
  242. },
  243. [isHidden, textAreaDisabled, enableButtons]
  244. )
  245. useEvent("message", handleMessage)
  246. useEffect(() => {
  247. const timer = setTimeout(() => {
  248. textAreaRef.current?.focus()
  249. }, 20)
  250. return () => {
  251. clearTimeout(timer)
  252. }
  253. }, [])
  254. useEffect(() => {
  255. if (textAreaRef.current && !textAreaHeight) {
  256. setTextAreaHeight(textAreaRef.current.offsetHeight)
  257. }
  258. }, [textAreaHeight])
  259. useEffect(() => {
  260. const timer = setTimeout(() => {
  261. if (!isHidden && !textAreaDisabled && !enableButtons) {
  262. textAreaRef.current?.focus()
  263. }
  264. }, 50)
  265. return () => {
  266. clearTimeout(timer)
  267. }
  268. }, [isHidden, textAreaDisabled, enableButtons])
  269. return (
  270. <div
  271. style={{
  272. position: "fixed",
  273. top: 0,
  274. left: 0,
  275. right: 0,
  276. bottom: 0,
  277. display: isHidden ? "none" : "flex",
  278. flexDirection: "column",
  279. overflow: "hidden",
  280. }}>
  281. {task ? (
  282. <TaskHeader
  283. taskText={task.text || ""}
  284. tokensIn={apiMetrics.totalTokensIn}
  285. tokensOut={apiMetrics.totalTokensOut}
  286. totalCost={apiMetrics.totalCost}
  287. onClose={handleTaskCloseButtonClick}
  288. isHidden={isHidden}
  289. />
  290. ) : (
  291. <div style={{ padding: "0 25px" }}>
  292. <h2>What can I do for you?</h2>
  293. <p>
  294. Thanks to{" "}
  295. <VSCodeLink
  296. href="https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf"
  297. style={{ display: "inline" }}>
  298. Claude 3.5 Sonnet's agentic coding capabilities,
  299. </VSCodeLink>{" "}
  300. I can handle complex software development tasks step-by-step. With tools that let me read &
  301. write files, create entire projects from scratch, and execute terminal commands (after you grant
  302. permission), I can assist you in ways that go beyond simple code completion or tech support.
  303. </p>
  304. </div>
  305. )}
  306. <div
  307. id="chat-view-container"
  308. className="scrollable"
  309. style={{
  310. flexGrow: 1,
  311. overflowY: "scroll",
  312. padding: "0 6px 0 15px",
  313. }}>
  314. {modifiedMessages.map((message, index) => (
  315. <ChatRow key={index} message={message} syntaxHighlighterStyle={syntaxHighlighterStyle} />
  316. ))}
  317. </div>
  318. <div
  319. style={{
  320. opacity: primaryButtonText || secondaryButtonText ? (enableButtons ? 1 : 0.5) : 0,
  321. display: "flex",
  322. padding: "10px 15px 0px 15px",
  323. }}>
  324. {primaryButtonText && (
  325. <VSCodeButton
  326. appearance="primary"
  327. disabled={!enableButtons}
  328. style={{
  329. flex: secondaryButtonText ? 1 : 2,
  330. marginRight: secondaryButtonText ? "6px" : "0",
  331. }}
  332. onClick={handlePrimaryButtonClick}>
  333. {primaryButtonText}
  334. </VSCodeButton>
  335. )}
  336. {secondaryButtonText && (
  337. <VSCodeButton
  338. appearance="secondary"
  339. disabled={!enableButtons}
  340. style={{ flex: 1, marginLeft: "6px" }}
  341. onClick={handleSecondaryButtonClick}>
  342. {secondaryButtonText}
  343. </VSCodeButton>
  344. )}
  345. </div>
  346. <div style={{ padding: "10px 15px", opacity: textAreaDisabled ? 0.5 : 1, position: "relative" }}>
  347. <DynamicTextArea
  348. ref={textAreaRef}
  349. value={inputValue}
  350. disabled={textAreaDisabled}
  351. onChange={(e) => setInputValue(e.target.value)}
  352. onKeyDown={handleKeyDown}
  353. onHeightChange={() => scrollToBottom(true)}
  354. placeholder={task ? "Type a message..." : "Type your task here..."}
  355. maxRows={10}
  356. autoFocus={true}
  357. style={{
  358. width: "100%",
  359. boxSizing: "border-box",
  360. backgroundColor: "var(--vscode-input-background)",
  361. color: "var(--vscode-input-foreground)",
  362. border: "1px solid var(--vscode-input-border)",
  363. borderRadius: 2,
  364. fontFamily: "var(--vscode-font-family)",
  365. fontSize: "var(--vscode-editor-font-size)",
  366. lineHeight: "var(--vscode-editor-line-height)",
  367. resize: "none",
  368. overflow: "hidden",
  369. padding: "8px 36px 8px 8px",
  370. cursor: textAreaDisabled ? "not-allowed" : undefined,
  371. }}
  372. />
  373. <div
  374. style={{
  375. position: "absolute",
  376. right: 20,
  377. display: "flex",
  378. alignItems: "center",
  379. ...(!!textAreaHeight ? { height: textAreaHeight, bottom: 12 } : { top: 0, bottom: 1.5 }),
  380. }}>
  381. <VSCodeButton
  382. disabled={textAreaDisabled}
  383. appearance="icon"
  384. aria-label="Send Message"
  385. onClick={handleSendMessage}>
  386. <span className="codicon codicon-send"></span>
  387. </VSCodeButton>
  388. </div>
  389. </div>
  390. </div>
  391. )
  392. }
  393. export default ChatView