ChatView.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
  2. import { KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
  3. import vsDarkPlus from "react-syntax-highlighter/dist/esm/styles/prism/vsc-dark-plus"
  4. import DynamicTextArea from "react-textarea-autosize"
  5. import { useEvent, useMount } from "react-use"
  6. import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
  7. import { ClaudeAsk, ClaudeMessage, ExtensionMessage } from "../../../src/shared/ExtensionMessage"
  8. import { getApiMetrics } from "../../../src/shared/getApiMetrics"
  9. import { combineApiRequests } from "../../../src/shared/combineApiRequests"
  10. import { combineCommandSequences } from "../../../src/shared/combineCommandSequences"
  11. import { getSyntaxHighlighterStyleFromTheme } from "../utils/getSyntaxHighlighterStyleFromTheme"
  12. import { vscode } from "../utils/vscode"
  13. import Announcement from "./Announcement"
  14. import ChatRow from "./ChatRow"
  15. import HistoryPreview from "./HistoryPreview"
  16. import TaskHeader from "./TaskHeader"
  17. import Thumbnails from "./Thumbnails"
  18. import { HistoryItem } from "../../../src/shared/HistoryItem"
  19. import { ApiConfiguration } from "../../../src/shared/api"
  20. import KoduPromo from "./KoduPromo"
  21. interface ChatViewProps {
  22. version: string
  23. messages: ClaudeMessage[]
  24. taskHistory: HistoryItem[]
  25. isHidden: boolean
  26. vscodeThemeName?: string
  27. showAnnouncement: boolean
  28. selectedModelSupportsImages: boolean
  29. selectedModelSupportsPromptCache: boolean
  30. hideAnnouncement: () => void
  31. showHistoryView: () => void
  32. apiConfiguration?: ApiConfiguration
  33. vscodeUriScheme?: string
  34. shouldShowKoduPromo: boolean
  35. koduCredits?: number
  36. }
  37. const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
  38. const ChatView = ({
  39. version,
  40. messages,
  41. taskHistory,
  42. isHidden,
  43. vscodeThemeName,
  44. showAnnouncement,
  45. selectedModelSupportsImages,
  46. selectedModelSupportsPromptCache,
  47. hideAnnouncement,
  48. showHistoryView,
  49. apiConfiguration,
  50. vscodeUriScheme,
  51. shouldShowKoduPromo,
  52. koduCredits,
  53. }: ChatViewProps) => {
  54. //const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
  55. 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)
  56. const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
  57. // has to be after api_req_finished are all reduced into api_req_started messages
  58. const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
  59. const [inputValue, setInputValue] = useState("")
  60. const textAreaRef = useRef<HTMLTextAreaElement>(null)
  61. const [textAreaDisabled, setTextAreaDisabled] = useState(false)
  62. const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
  63. const [selectedImages, setSelectedImages] = useState<string[]>([])
  64. const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
  65. // 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)
  66. const [claudeAsk, setClaudeAsk] = useState<ClaudeAsk | undefined>(undefined)
  67. const [enableButtons, setEnableButtons] = useState<boolean>(false)
  68. const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
  69. const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
  70. const [syntaxHighlighterStyle, setSyntaxHighlighterStyle] = useState(vsDarkPlus)
  71. const virtuosoRef = useRef<VirtuosoHandle>(null)
  72. const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
  73. const toggleRowExpansion = (ts: number) => {
  74. setExpandedRows((prev) => ({
  75. ...prev,
  76. [ts]: !prev[ts],
  77. }))
  78. }
  79. useEffect(() => {
  80. if (!vscodeThemeName) return
  81. const theme = getSyntaxHighlighterStyleFromTheme(vscodeThemeName)
  82. if (theme) {
  83. setSyntaxHighlighterStyle(theme)
  84. }
  85. }, [vscodeThemeName])
  86. useEffect(() => {
  87. // if last message is an ask, show user ask UI
  88. // 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.
  89. // basically as long as a task is active, the conversation history will be persisted
  90. const lastMessage = messages.at(-1)
  91. if (lastMessage) {
  92. switch (lastMessage.type) {
  93. case "ask":
  94. switch (lastMessage.ask) {
  95. case "request_limit_reached":
  96. setTextAreaDisabled(true)
  97. setClaudeAsk("request_limit_reached")
  98. setEnableButtons(true)
  99. setPrimaryButtonText("Proceed")
  100. setSecondaryButtonText("Start New Task")
  101. break
  102. case "api_req_failed":
  103. setTextAreaDisabled(true)
  104. setClaudeAsk("api_req_failed")
  105. setEnableButtons(true)
  106. setPrimaryButtonText("Retry")
  107. setSecondaryButtonText("Start New Task")
  108. break
  109. case "followup":
  110. setTextAreaDisabled(false)
  111. setClaudeAsk("followup")
  112. setEnableButtons(false)
  113. // setPrimaryButtonText(undefined)
  114. // setSecondaryButtonText(undefined)
  115. break
  116. case "tool":
  117. setTextAreaDisabled(false)
  118. setClaudeAsk("tool")
  119. setEnableButtons(true)
  120. setPrimaryButtonText("Approve")
  121. setSecondaryButtonText("Reject")
  122. break
  123. case "command":
  124. setTextAreaDisabled(false)
  125. setClaudeAsk("command")
  126. setEnableButtons(true)
  127. setPrimaryButtonText("Run Command")
  128. setSecondaryButtonText("Reject")
  129. break
  130. case "command_output":
  131. setTextAreaDisabled(false)
  132. setClaudeAsk("command_output")
  133. setEnableButtons(true)
  134. setPrimaryButtonText("Exit Command")
  135. setSecondaryButtonText(undefined)
  136. break
  137. case "completion_result":
  138. // extension waiting for feedback. but we can just present a new task button
  139. setTextAreaDisabled(false)
  140. setClaudeAsk("completion_result")
  141. setEnableButtons(true)
  142. setPrimaryButtonText("Start New Task")
  143. setSecondaryButtonText(undefined)
  144. break
  145. case "resume_task":
  146. setTextAreaDisabled(false)
  147. setClaudeAsk("resume_task")
  148. setEnableButtons(true)
  149. setPrimaryButtonText("Resume Task")
  150. setSecondaryButtonText(undefined)
  151. break
  152. case "resume_completed_task":
  153. setTextAreaDisabled(false)
  154. setClaudeAsk("resume_completed_task")
  155. setEnableButtons(true)
  156. setPrimaryButtonText("Start New Task")
  157. setSecondaryButtonText(undefined)
  158. break
  159. }
  160. break
  161. case "say":
  162. // don't want to reset since there could be a "say" after an "ask" while ask is waiting for response
  163. switch (lastMessage.say) {
  164. case "api_req_started":
  165. if (messages.at(-2)?.ask === "command_output") {
  166. // 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)
  167. setInputValue("")
  168. setTextAreaDisabled(true)
  169. setSelectedImages([])
  170. setClaudeAsk(undefined)
  171. setEnableButtons(false)
  172. }
  173. break
  174. case "task":
  175. case "error":
  176. case "api_req_finished":
  177. case "text":
  178. case "command_output":
  179. case "completion_result":
  180. case "tool":
  181. break
  182. }
  183. break
  184. }
  185. } else {
  186. // this would get called after sending the first message, so we have to watch messages.length instead
  187. // No messages, so user has to submit a task
  188. // setTextAreaDisabled(false)
  189. // setClaudeAsk(undefined)
  190. // setPrimaryButtonText(undefined)
  191. // setSecondaryButtonText(undefined)
  192. }
  193. }, [messages])
  194. useEffect(() => {
  195. if (messages.length === 0) {
  196. setTextAreaDisabled(false)
  197. setClaudeAsk(undefined)
  198. setEnableButtons(false)
  199. setPrimaryButtonText(undefined)
  200. setSecondaryButtonText(undefined)
  201. }
  202. }, [messages.length])
  203. const handleSendMessage = () => {
  204. const text = inputValue.trim()
  205. if (text || selectedImages.length > 0) {
  206. if (messages.length === 0) {
  207. vscode.postMessage({ type: "newTask", text, images: selectedImages })
  208. } else if (claudeAsk) {
  209. switch (claudeAsk) {
  210. case "followup":
  211. case "tool":
  212. case "command": // user can provide feedback to a tool or command use
  213. case "command_output": // user can send input to command stdin
  214. case "completion_result": // if this happens then the user has feedback for the completion result
  215. case "resume_task":
  216. case "resume_completed_task":
  217. vscode.postMessage({
  218. type: "askResponse",
  219. askResponse: "messageResponse",
  220. text,
  221. images: selectedImages,
  222. })
  223. break
  224. // there is no other case that a textfield should be enabled
  225. }
  226. }
  227. setInputValue("")
  228. setTextAreaDisabled(true)
  229. setSelectedImages([])
  230. setClaudeAsk(undefined)
  231. setEnableButtons(false)
  232. // setPrimaryButtonText(undefined)
  233. // setSecondaryButtonText(undefined)
  234. }
  235. }
  236. /*
  237. 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.
  238. */
  239. const handlePrimaryButtonClick = () => {
  240. switch (claudeAsk) {
  241. case "request_limit_reached":
  242. case "api_req_failed":
  243. case "command":
  244. case "command_output":
  245. case "tool":
  246. case "resume_task":
  247. vscode.postMessage({ type: "askResponse", askResponse: "yesButtonTapped" })
  248. break
  249. case "completion_result":
  250. case "resume_completed_task":
  251. // extension waiting for feedback. but we can just present a new task button
  252. startNewTask()
  253. break
  254. }
  255. setTextAreaDisabled(true)
  256. setClaudeAsk(undefined)
  257. setEnableButtons(false)
  258. // setPrimaryButtonText(undefined)
  259. // setSecondaryButtonText(undefined)
  260. }
  261. const handleSecondaryButtonClick = () => {
  262. switch (claudeAsk) {
  263. case "request_limit_reached":
  264. case "api_req_failed":
  265. startNewTask()
  266. break
  267. case "command":
  268. case "tool":
  269. // responds to the API with a "This operation failed" and lets it try again
  270. vscode.postMessage({ type: "askResponse", askResponse: "noButtonTapped" })
  271. break
  272. }
  273. setTextAreaDisabled(true)
  274. setClaudeAsk(undefined)
  275. setEnableButtons(false)
  276. // setPrimaryButtonText(undefined)
  277. // setSecondaryButtonText(undefined)
  278. }
  279. const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
  280. const isComposing = event.nativeEvent?.isComposing ?? false
  281. if (event.key === "Enter" && !event.shiftKey && !isComposing) {
  282. event.preventDefault()
  283. handleSendMessage()
  284. }
  285. }
  286. const handleTaskCloseButtonClick = () => {
  287. startNewTask()
  288. }
  289. const startNewTask = () => {
  290. vscode.postMessage({ type: "clearTask" })
  291. }
  292. const selectImages = () => {
  293. vscode.postMessage({ type: "selectImages" })
  294. }
  295. const handlePaste = async (e: React.ClipboardEvent) => {
  296. if (shouldDisableImages) {
  297. e.preventDefault()
  298. return
  299. }
  300. const items = e.clipboardData.items
  301. const acceptedTypes = ["png", "jpeg", "webp"] // supported by anthropic and openrouter (jpg is just a file extension but the image will be recognized as jpeg)
  302. const imageItems = Array.from(items).filter((item) => {
  303. const [type, subtype] = item.type.split("/")
  304. return type === "image" && acceptedTypes.includes(subtype)
  305. })
  306. if (imageItems.length > 0) {
  307. e.preventDefault()
  308. const imagePromises = imageItems.map((item) => {
  309. return new Promise<string | null>((resolve) => {
  310. const blob = item.getAsFile()
  311. if (!blob) {
  312. resolve(null)
  313. return
  314. }
  315. const reader = new FileReader()
  316. reader.onloadend = () => {
  317. if (reader.error) {
  318. console.error("Error reading file:", reader.error)
  319. resolve(null)
  320. } else {
  321. const result = reader.result
  322. resolve(typeof result === "string" ? result : null)
  323. }
  324. }
  325. reader.readAsDataURL(blob)
  326. })
  327. })
  328. const imageDataArray = await Promise.all(imagePromises)
  329. const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
  330. //.map((dataUrl) => dataUrl.split(",")[1]) // strip the mime type prefix, sharp doesn't need it
  331. if (dataUrls.length > 0) {
  332. setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
  333. } else {
  334. console.warn("No valid images were processed")
  335. }
  336. }
  337. }
  338. useEffect(() => {
  339. if (selectedImages.length === 0) {
  340. setThumbnailsHeight(0)
  341. }
  342. }, [selectedImages])
  343. const handleThumbnailsHeightChange = useCallback((height: number) => {
  344. setThumbnailsHeight(height)
  345. }, [])
  346. const handleMessage = useCallback(
  347. (e: MessageEvent) => {
  348. const message: ExtensionMessage = e.data
  349. switch (message.type) {
  350. case "action":
  351. switch (message.action!) {
  352. case "didBecomeVisible":
  353. if (!isHidden && !textAreaDisabled && !enableButtons) {
  354. textAreaRef.current?.focus()
  355. }
  356. break
  357. }
  358. break
  359. case "selectedImages":
  360. const newImages = message.images ?? []
  361. if (newImages.length > 0) {
  362. setSelectedImages((prevImages) =>
  363. [...prevImages, ...newImages].slice(0, MAX_IMAGES_PER_MESSAGE)
  364. )
  365. }
  366. break
  367. }
  368. // 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.
  369. },
  370. [isHidden, textAreaDisabled, enableButtons]
  371. )
  372. useEvent("message", handleMessage)
  373. useMount(() => {
  374. // NOTE: the vscode window needs to be focused for this to work
  375. textAreaRef.current?.focus()
  376. })
  377. useEffect(() => {
  378. const timer = setTimeout(() => {
  379. if (!isHidden && !textAreaDisabled && !enableButtons) {
  380. textAreaRef.current?.focus()
  381. }
  382. }, 50)
  383. return () => {
  384. clearTimeout(timer)
  385. }
  386. }, [isHidden, textAreaDisabled, enableButtons])
  387. const visibleMessages = useMemo(() => {
  388. return modifiedMessages.filter((message) => {
  389. switch (message.ask) {
  390. case "completion_result":
  391. // 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.
  392. if (message.text === "") {
  393. return false
  394. }
  395. break
  396. case "api_req_failed": // this message is used to update the latest api_req_started that the request failed
  397. case "resume_task":
  398. case "resume_completed_task":
  399. return false
  400. }
  401. switch (message.say) {
  402. case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
  403. case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried
  404. return false
  405. case "text":
  406. // Sometimes Claude 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)
  407. if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
  408. return false
  409. }
  410. break
  411. }
  412. return true
  413. })
  414. }, [modifiedMessages])
  415. useEffect(() => {
  416. // 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.
  417. const timer = setTimeout(() => {
  418. // 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
  419. // NOTE: scroll to bottom may not work if you use margin, see virtuoso's troubleshooting
  420. virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" })
  421. }, 50)
  422. return () => clearTimeout(timer)
  423. }, [visibleMessages])
  424. const [placeholderText, isInputPipingToStdin] = useMemo(() => {
  425. if (messages.at(-1)?.ask === "command_output") {
  426. return ["Type input to command stdin...", true]
  427. }
  428. const text = task ? "Type a message..." : "Type your task here..."
  429. return [text, false]
  430. }, [task, messages])
  431. const shouldDisableImages =
  432. !selectedModelSupportsImages ||
  433. textAreaDisabled ||
  434. selectedImages.length >= MAX_IMAGES_PER_MESSAGE ||
  435. isInputPipingToStdin
  436. return (
  437. <div
  438. style={{
  439. position: "fixed",
  440. top: 0,
  441. left: 0,
  442. right: 0,
  443. bottom: 0,
  444. display: isHidden ? "none" : "flex",
  445. flexDirection: "column",
  446. overflow: "hidden",
  447. }}>
  448. {task ? (
  449. <TaskHeader
  450. task={task}
  451. tokensIn={apiMetrics.totalTokensIn}
  452. tokensOut={apiMetrics.totalTokensOut}
  453. doesModelSupportPromptCache={selectedModelSupportsPromptCache}
  454. cacheWrites={apiMetrics.totalCacheWrites}
  455. cacheReads={apiMetrics.totalCacheReads}
  456. totalCost={apiMetrics.totalCost}
  457. onClose={handleTaskCloseButtonClick}
  458. isHidden={isHidden}
  459. koduCredits={koduCredits}
  460. vscodeUriScheme={vscodeUriScheme}
  461. apiProvider={apiConfiguration?.apiProvider}
  462. />
  463. ) : (
  464. <>
  465. {showAnnouncement && (
  466. <Announcement
  467. version={version}
  468. hideAnnouncement={hideAnnouncement}
  469. apiConfiguration={apiConfiguration}
  470. vscodeUriScheme={vscodeUriScheme}
  471. />
  472. )}
  473. {apiConfiguration?.koduApiKey === undefined && !showAnnouncement && shouldShowKoduPromo && (
  474. <KoduPromo vscodeUriScheme={vscodeUriScheme} style={{ margin: "10px 15px -10px 15px" }} />
  475. )}
  476. <div style={{ padding: "0 20px", flexGrow: taskHistory.length > 0 ? undefined : 1 }}>
  477. <h2>What can I do for you?</h2>
  478. <p>
  479. Thanks to{" "}
  480. <VSCodeLink
  481. href="https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf"
  482. style={{ display: "inline" }}>
  483. Claude 3.5 Sonnet's agentic coding capabilities,
  484. </VSCodeLink>{" "}
  485. I can handle complex software development tasks step-by-step. With tools that let me create
  486. & edit files, explore complex projects, and execute terminal commands (after you grant
  487. permission), I can assist you in ways that go beyond simple code completion or tech support.
  488. </p>
  489. </div>
  490. {taskHistory.length > 0 && (
  491. <HistoryPreview taskHistory={taskHistory} showHistoryView={showHistoryView} />
  492. )}
  493. </>
  494. )}
  495. {task && (
  496. <>
  497. <Virtuoso
  498. ref={virtuosoRef}
  499. className="scrollable"
  500. style={{
  501. flexGrow: 1,
  502. overflowY: "scroll", // always show scrollbar
  503. }}
  504. // followOutput={(isAtBottom) => {
  505. // const lastMessage = modifiedMessages.at(-1)
  506. // if (lastMessage && shouldShowChatRow(lastMessage)) {
  507. // return "smooth"
  508. // }
  509. // return false
  510. // }}
  511. 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)
  512. 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
  513. itemContent={(index, message) => (
  514. <ChatRow
  515. key={message.ts}
  516. message={message}
  517. syntaxHighlighterStyle={syntaxHighlighterStyle}
  518. isExpanded={expandedRows[message.ts] || false}
  519. onToggleExpand={() => toggleRowExpansion(message.ts)}
  520. lastModifiedMessage={modifiedMessages.at(-1)}
  521. isLast={index === visibleMessages.length - 1}
  522. apiProvider={apiConfiguration?.apiProvider}
  523. />
  524. )}
  525. />
  526. <div
  527. style={{
  528. opacity: primaryButtonText || secondaryButtonText ? (enableButtons ? 1 : 0.5) : 0,
  529. display: "flex",
  530. padding: "10px 15px 0px 15px",
  531. }}>
  532. {primaryButtonText && (
  533. <VSCodeButton
  534. appearance="primary"
  535. disabled={!enableButtons}
  536. style={{
  537. flex: secondaryButtonText ? 1 : 2,
  538. marginRight: secondaryButtonText ? "6px" : "0",
  539. }}
  540. onClick={handlePrimaryButtonClick}>
  541. {primaryButtonText}
  542. </VSCodeButton>
  543. )}
  544. {secondaryButtonText && (
  545. <VSCodeButton
  546. appearance="secondary"
  547. disabled={!enableButtons}
  548. style={{ flex: 1, marginLeft: "6px" }}
  549. onClick={handleSecondaryButtonClick}>
  550. {secondaryButtonText}
  551. </VSCodeButton>
  552. )}
  553. </div>
  554. </>
  555. )}
  556. <div
  557. style={{
  558. padding: "10px 15px",
  559. opacity: textAreaDisabled ? 0.5 : 1,
  560. position: "relative",
  561. display: "flex",
  562. }}>
  563. {!isTextAreaFocused && (
  564. <div
  565. style={{
  566. position: "absolute",
  567. inset: "10px 15px",
  568. border: "1px solid var(--vscode-input-border)",
  569. borderRadius: 2,
  570. pointerEvents: "none",
  571. }}
  572. />
  573. )}
  574. <DynamicTextArea
  575. ref={textAreaRef}
  576. value={inputValue}
  577. disabled={textAreaDisabled}
  578. onChange={(e) => setInputValue(e.target.value)}
  579. onKeyDown={handleKeyDown}
  580. onFocus={() => setIsTextAreaFocused(true)}
  581. onBlur={() => setIsTextAreaFocused(false)}
  582. onPaste={handlePaste}
  583. onHeightChange={() =>
  584. //virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "auto" })
  585. virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" })
  586. }
  587. placeholder={placeholderText}
  588. maxRows={10}
  589. autoFocus={true}
  590. style={{
  591. width: "100%",
  592. boxSizing: "border-box",
  593. backgroundColor: "var(--vscode-input-background)",
  594. color: "var(--vscode-input-foreground)",
  595. //border: "1px solid var(--vscode-input-border)",
  596. borderRadius: 2,
  597. fontFamily: "var(--vscode-font-family)",
  598. fontSize: "var(--vscode-editor-font-size)",
  599. lineHeight: "var(--vscode-editor-line-height)",
  600. resize: "none",
  601. overflow: "hidden",
  602. // Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410)
  603. borderTop: "9px solid transparent",
  604. borderBottom: `${thumbnailsHeight + 9}px solid transparent`,
  605. borderRight: "54px solid transparent",
  606. borderLeft: "9px solid transparent",
  607. // Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused
  608. // boxShadow: "0px 0px 0px 1px var(--vscode-input-border)",
  609. padding: 0,
  610. cursor: textAreaDisabled ? "not-allowed" : undefined,
  611. flex: 1,
  612. }}
  613. />
  614. {selectedImages.length > 0 && (
  615. <Thumbnails
  616. images={selectedImages}
  617. setImages={setSelectedImages}
  618. onHeightChange={handleThumbnailsHeightChange}
  619. style={{
  620. position: "absolute",
  621. paddingTop: 4,
  622. bottom: 14,
  623. left: 22,
  624. right: 67, // (54 + 9) + 4 extra padding
  625. }}
  626. />
  627. )}
  628. <div
  629. style={{
  630. position: "absolute",
  631. right: 20,
  632. bottom: 14.5, // Align with the bottom padding of the container
  633. display: "flex",
  634. alignItems: "flex-end",
  635. height: "calc(100% - 20px)", // Full height minus top and bottom padding
  636. }}>
  637. <VSCodeButton
  638. disabled={shouldDisableImages}
  639. appearance="icon"
  640. aria-label="Attach Images"
  641. onClick={selectImages}
  642. style={{ marginRight: "2px" }}>
  643. <span
  644. className="codicon codicon-device-camera"
  645. style={{ fontSize: 18, marginLeft: -2, marginTop: -1.5 }}></span>
  646. </VSCodeButton>
  647. <VSCodeButton
  648. disabled={textAreaDisabled}
  649. appearance="icon"
  650. aria-label="Send Message"
  651. onClick={handleSendMessage}>
  652. <span className="codicon codicon-send" style={{ fontSize: 16, marginTop: 1.5 }}></span>
  653. </VSCodeButton>
  654. </div>
  655. </div>
  656. </div>
  657. )
  658. }
  659. export default ChatView