ChatView.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import { ClaudeAsk, ClaudeMessage, ExtensionMessage } from "@shared/ExtensionMessage"
  2. import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
  3. import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"
  4. import DynamicTextArea from "react-textarea-autosize"
  5. import { vscode } from "../utilities/vscode"
  6. import { ClaudeAskResponse } from "@shared/WebviewMessage"
  7. import ChatRow from "./ChatRow"
  8. import { combineCommandSequences } from "../utilities/combineCommandSequences"
  9. import { combineApiRequests } from "../utilities/combineApiRequests"
  10. import TaskHeader from "./TaskHeader"
  11. import { getApiMetrics } from "../utilities/getApiMetrics"
  12. interface ChatViewProps {
  13. messages: ClaudeMessage[]
  14. }
  15. // 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)
  16. const ChatView = ({ messages }: ChatViewProps) => {
  17. const task = messages.shift()
  18. const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages)), [messages])
  19. // has to be after api_req_finished are all reduced into api_req_started messages
  20. const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
  21. const [inputValue, setInputValue] = useState("")
  22. const messagesEndRef = useRef<HTMLDivElement>(null)
  23. const textAreaRef = useRef<HTMLTextAreaElement>(null)
  24. const [textAreaHeight, setTextAreaHeight] = useState<number | undefined>(undefined)
  25. const [textAreaDisabled, setTextAreaDisabled] = useState(false)
  26. const [claudeAsk, setClaudeAsk] = useState<ClaudeAsk | undefined>(undefined)
  27. const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
  28. const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
  29. const scrollToBottom = (instant: boolean = false) => {
  30. // https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
  31. ;(messagesEndRef.current as any)?.scrollIntoView({
  32. behavior: instant ? "instant" : "smooth",
  33. block: "nearest",
  34. inline: "start",
  35. })
  36. }
  37. const handlePrimaryButtonClick = () => {
  38. //vscode.postMessage({ type: "askResponse", askResponse: "primaryButton" })
  39. setPrimaryButtonText(undefined)
  40. setSecondaryButtonText(undefined)
  41. }
  42. // New function to handle secondary button click
  43. const handleSecondaryButtonClick = () => {
  44. //vscode.postMessage({ type: "askResponse", askResponse: "secondaryButton" })
  45. setPrimaryButtonText(undefined)
  46. setSecondaryButtonText(undefined)
  47. }
  48. // scroll to bottom when new message is added
  49. const visibleMessages = useMemo(
  50. () =>
  51. modifiedMessages.filter(
  52. (message) => !(message.type === "ask" && message.ask === "completion_result" && message.text === "")
  53. ),
  54. [modifiedMessages]
  55. )
  56. useEffect(() => {
  57. scrollToBottom()
  58. }, [visibleMessages.length])
  59. useEffect(() => {
  60. // if last message is an ask, show user ask UI
  61. // 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.
  62. // basically as long as a task is active, the conversation history will be persisted
  63. const lastMessage = messages.at(-1)
  64. if (lastMessage) {
  65. if (lastMessage.type === "ask") {
  66. //setTextAreaDisabled(false) // should enable for certain asks
  67. setClaudeAsk(lastMessage.ask)
  68. // Set button texts based on the ask
  69. // setPrimaryButtonText(lastMessage.ask === "command" ? "Yes" : "Continue")
  70. // setSecondaryButtonText(lastMessage.ask === "yesno" ? "No" : undefined)
  71. setPrimaryButtonText("Yes")
  72. setSecondaryButtonText("No")
  73. } else {
  74. //setTextAreaDisabled(true)
  75. setClaudeAsk(undefined)
  76. // setPrimaryButtonText(undefined)
  77. // setSecondaryButtonText(undefined)
  78. setPrimaryButtonText("Yes")
  79. setSecondaryButtonText("No")
  80. }
  81. }
  82. }, [messages])
  83. const handleSendMessage = () => {
  84. const text = inputValue.trim()
  85. if (text) {
  86. setInputValue("")
  87. if (messages.length === 0) {
  88. vscode.postMessage({ type: "newTask", text })
  89. } else if (claudeAsk) {
  90. switch (claudeAsk) {
  91. case "followup":
  92. vscode.postMessage({ type: "askResponse", askResponse: "textResponse", text })
  93. break
  94. // case "completion_result":
  95. // vscode.postMessage({ type: "askResponse", text })
  96. // break
  97. default:
  98. // for now we'll type the askResponses
  99. vscode.postMessage({ type: "askResponse", askResponse: text as ClaudeAskResponse })
  100. break
  101. }
  102. }
  103. }
  104. }
  105. // handle ask buttons
  106. // be sure to setInputValue("")
  107. const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
  108. if (event.key === "Enter" && !event.shiftKey) {
  109. event.preventDefault()
  110. handleSendMessage()
  111. }
  112. }
  113. const handleTaskCloseButtonClick = () => {
  114. vscode.postMessage({ type: "abortTask" })
  115. }
  116. useEffect(() => {
  117. if (textAreaRef.current && !textAreaHeight) {
  118. setTextAreaHeight(textAreaRef.current.offsetHeight)
  119. //textAreaRef.current.focus()
  120. }
  121. const handleMessage = (e: MessageEvent) => {
  122. const message: ExtensionMessage = e.data
  123. switch (message.type) {
  124. case "action":
  125. switch (message.action!) {
  126. case "didBecomeVisible":
  127. textAreaRef.current?.focus()
  128. break
  129. }
  130. break
  131. }
  132. }
  133. window.addEventListener("message", handleMessage)
  134. const timer = setTimeout(() => {
  135. textAreaRef.current?.focus()
  136. }, 20)
  137. return () => {
  138. clearTimeout(timer)
  139. window.removeEventListener("message", handleMessage)
  140. }
  141. // eslint-disable-next-line react-hooks/exhaustive-deps
  142. }, [])
  143. return (
  144. <div
  145. style={{
  146. position: "fixed",
  147. top: 0,
  148. left: 0,
  149. right: 0,
  150. bottom: 0,
  151. display: "flex",
  152. flexDirection: "column",
  153. overflow: "hidden",
  154. }}>
  155. <TaskHeader
  156. taskText={task?.text || ""}
  157. tokensIn={apiMetrics.totalTokensIn}
  158. tokensOut={apiMetrics.totalTokensOut}
  159. totalCost={apiMetrics.totalCost}
  160. onClose={handleTaskCloseButtonClick}
  161. />
  162. <div
  163. className="scrollable"
  164. style={{
  165. flexGrow: 1,
  166. overflowY: "auto",
  167. }}>
  168. {modifiedMessages.map((message, index) => (
  169. <ChatRow key={index} message={message} />
  170. ))}
  171. <div style={{ float: "left", clear: "both" }} ref={messagesEndRef} />
  172. </div>
  173. {(primaryButtonText || secondaryButtonText) && (
  174. <div style={{ display: "flex", padding: "10px 15px 0px 15px" }}>
  175. {primaryButtonText && (
  176. <VSCodeButton
  177. appearance="primary"
  178. style={{
  179. flex: secondaryButtonText ? 1 : 2,
  180. marginRight: secondaryButtonText ? "6px" : "0",
  181. }}
  182. onClick={handlePrimaryButtonClick}>
  183. {primaryButtonText}
  184. </VSCodeButton>
  185. )}
  186. {secondaryButtonText && (
  187. <VSCodeButton
  188. appearance="secondary"
  189. style={{ flex: 1, marginLeft: "6px" }}
  190. onClick={handleSecondaryButtonClick}>
  191. {secondaryButtonText}
  192. </VSCodeButton>
  193. )}
  194. </div>
  195. )}
  196. <div style={{ padding: "10px 15px" }}>
  197. <DynamicTextArea
  198. ref={textAreaRef}
  199. value={inputValue}
  200. disabled={textAreaDisabled}
  201. onChange={(e) => setInputValue(e.target.value)}
  202. onKeyDown={handleKeyDown}
  203. onHeightChange={() => scrollToBottom(true)}
  204. placeholder="Type a message..."
  205. maxRows={10}
  206. autoFocus={true}
  207. style={{
  208. width: "100%",
  209. boxSizing: "border-box",
  210. backgroundColor: "var(--vscode-input-background)",
  211. color: "var(--vscode-input-foreground)",
  212. border: "1px solid var(--vscode-input-border)",
  213. borderRadius: "2px",
  214. fontFamily: "var(--vscode-font-family)",
  215. fontSize: "var(--vscode-editor-font-size)",
  216. lineHeight: "var(--vscode-editor-line-height)",
  217. resize: "none",
  218. overflow: "hidden",
  219. padding: "8px 40px 8px 8px",
  220. }}
  221. />
  222. {textAreaHeight && (
  223. <div
  224. style={{
  225. position: "absolute",
  226. right: "18px",
  227. height: `${textAreaHeight}px`,
  228. bottom: "12px",
  229. display: "flex",
  230. alignItems: "center",
  231. }}>
  232. <VSCodeButton appearance="icon" aria-label="Send Message" onClick={handleSendMessage}>
  233. <span className="codicon codicon-send"></span>
  234. </VSCodeButton>
  235. </div>
  236. )}
  237. </div>
  238. </div>
  239. )
  240. }
  241. export default ChatView