ChatView.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
  2. import { useCallback, useEffect, useMemo, useRef, useState } from "react"
  3. import { useEvent, useMount } from "react-use"
  4. import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
  5. import { ClaudeAsk, ClaudeSayTool, ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
  6. import { combineApiRequests } from "../../../../src/shared/combineApiRequests"
  7. import { combineCommandSequences } from "../../../../src/shared/combineCommandSequences"
  8. import { getApiMetrics } from "../../../../src/shared/getApiMetrics"
  9. import { useExtensionState } from "../../context/ExtensionStateContext"
  10. import { vscode } from "../../utils/vscode"
  11. import Announcement from "./Announcement"
  12. import { normalizeApiConfiguration } from "../settings/ApiOptions"
  13. import ChatRow from "./ChatRow"
  14. import ChatTextArea from "./ChatTextArea"
  15. import HistoryPreview from "../history/HistoryPreview"
  16. import TaskHeader from "./TaskHeader"
  17. interface ChatViewProps {
  18. isHidden: boolean
  19. showAnnouncement: boolean
  20. hideAnnouncement: () => void
  21. showHistoryView: () => void
  22. }
  23. export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
  24. const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
  25. const { version, claudeMessages: messages, taskHistory, apiConfiguration } = useExtensionState()
  26. //const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
  27. 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)
  28. const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
  29. // has to be after api_req_finished are all reduced into api_req_started messages
  30. const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
  31. const [inputValue, setInputValue] = useState("")
  32. const textAreaRef = useRef<HTMLTextAreaElement>(null)
  33. const [textAreaDisabled, setTextAreaDisabled] = useState(false)
  34. const [selectedImages, setSelectedImages] = useState<string[]>([])
  35. // 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)
  36. const [claudeAsk, setClaudeAsk] = useState<ClaudeAsk | undefined>(undefined)
  37. const [enableButtons, setEnableButtons] = useState<boolean>(false)
  38. const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
  39. const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
  40. const virtuosoRef = useRef<VirtuosoHandle>(null)
  41. const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
  42. const [isAtBottom, setIsAtBottom] = useState(false)
  43. const [didScrollFromApiReqTs, setDidScrollFromApiReqTs] = useState<number | undefined>(undefined)
  44. useEffect(() => {
  45. // if last message is an ask, show user ask UI
  46. // 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.
  47. // basically as long as a task is active, the conversation history will be persisted
  48. const lastMessage = messages.at(-1)
  49. if (lastMessage) {
  50. switch (lastMessage.type) {
  51. case "ask":
  52. switch (lastMessage.ask) {
  53. case "api_req_failed":
  54. setTextAreaDisabled(true)
  55. setClaudeAsk("api_req_failed")
  56. setEnableButtons(true)
  57. setPrimaryButtonText("Retry")
  58. setSecondaryButtonText("Start New Task")
  59. break
  60. case "mistake_limit_reached":
  61. setTextAreaDisabled(false)
  62. setClaudeAsk("mistake_limit_reached")
  63. setEnableButtons(true)
  64. setPrimaryButtonText("Proceed Anyways")
  65. setSecondaryButtonText("Start New Task")
  66. break
  67. case "followup":
  68. setTextAreaDisabled(false)
  69. setClaudeAsk("followup")
  70. setEnableButtons(false)
  71. // setPrimaryButtonText(undefined)
  72. // setSecondaryButtonText(undefined)
  73. break
  74. case "tool":
  75. setTextAreaDisabled(false)
  76. setClaudeAsk("tool")
  77. setEnableButtons(true)
  78. const tool = JSON.parse(lastMessage.text || "{}") as ClaudeSayTool
  79. switch (tool.tool) {
  80. case "editedExistingFile":
  81. case "newFileCreated":
  82. setPrimaryButtonText("Save")
  83. setSecondaryButtonText("Reject")
  84. break
  85. default:
  86. setPrimaryButtonText("Approve")
  87. setSecondaryButtonText("Reject")
  88. break
  89. }
  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("Proceed While Running")
  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. case "resume_task":
  114. setTextAreaDisabled(false)
  115. setClaudeAsk("resume_task")
  116. setEnableButtons(true)
  117. setPrimaryButtonText("Resume Task")
  118. setSecondaryButtonText(undefined)
  119. break
  120. case "resume_completed_task":
  121. setTextAreaDisabled(false)
  122. setClaudeAsk("resume_completed_task")
  123. setEnableButtons(true)
  124. setPrimaryButtonText("Start New Task")
  125. setSecondaryButtonText(undefined)
  126. break
  127. }
  128. break
  129. case "say":
  130. // don't want to reset since there could be a "say" after an "ask" while ask is waiting for response
  131. switch (lastMessage.say) {
  132. case "api_req_started":
  133. if (messages.at(-2)?.ask === "command_output") {
  134. // 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)
  135. setInputValue("")
  136. setTextAreaDisabled(true)
  137. setSelectedImages([])
  138. setClaudeAsk(undefined)
  139. setEnableButtons(false)
  140. }
  141. break
  142. case "task":
  143. case "error":
  144. case "api_req_finished":
  145. case "text":
  146. case "inspect_site_result":
  147. case "command_output":
  148. case "completion_result":
  149. case "tool":
  150. break
  151. }
  152. break
  153. }
  154. } else {
  155. // this would get called after sending the first message, so we have to watch messages.length instead
  156. // No messages, so user has to submit a task
  157. // setTextAreaDisabled(false)
  158. // setClaudeAsk(undefined)
  159. // setPrimaryButtonText(undefined)
  160. // setSecondaryButtonText(undefined)
  161. }
  162. }, [messages])
  163. useEffect(() => {
  164. if (messages.length === 0) {
  165. setTextAreaDisabled(false)
  166. setClaudeAsk(undefined)
  167. setEnableButtons(false)
  168. setPrimaryButtonText(undefined)
  169. setSecondaryButtonText(undefined)
  170. }
  171. }, [messages.length])
  172. const handleSendMessage = useCallback(
  173. (text: string, images: string[]) => {
  174. text = text.trim()
  175. if (text || images.length > 0) {
  176. if (messages.length === 0) {
  177. vscode.postMessage({ type: "newTask", text, images })
  178. } else if (claudeAsk) {
  179. switch (claudeAsk) {
  180. case "followup":
  181. case "tool":
  182. case "command": // user can provide feedback to a tool or command use
  183. case "command_output": // user can send input to command stdin
  184. case "completion_result": // if this happens then the user has feedback for the completion result
  185. case "resume_task":
  186. case "resume_completed_task":
  187. case "mistake_limit_reached":
  188. vscode.postMessage({
  189. type: "askResponse",
  190. askResponse: "messageResponse",
  191. text,
  192. images,
  193. })
  194. break
  195. // there is no other case that a textfield should be enabled
  196. }
  197. }
  198. setInputValue("")
  199. setTextAreaDisabled(true)
  200. setSelectedImages([])
  201. setClaudeAsk(undefined)
  202. setEnableButtons(false)
  203. // setPrimaryButtonText(undefined)
  204. // setSecondaryButtonText(undefined)
  205. }
  206. },
  207. [messages.length, claudeAsk]
  208. )
  209. const startNewTask = useCallback(() => {
  210. vscode.postMessage({ type: "clearTask" })
  211. }, [])
  212. /*
  213. 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.
  214. */
  215. const handlePrimaryButtonClick = useCallback(() => {
  216. switch (claudeAsk) {
  217. case "api_req_failed":
  218. case "command":
  219. case "command_output":
  220. case "tool":
  221. case "resume_task":
  222. case "mistake_limit_reached":
  223. vscode.postMessage({ type: "askResponse", askResponse: "yesButtonTapped" })
  224. break
  225. case "completion_result":
  226. case "resume_completed_task":
  227. // extension waiting for feedback. but we can just present a new task button
  228. startNewTask()
  229. break
  230. }
  231. setTextAreaDisabled(true)
  232. setClaudeAsk(undefined)
  233. setEnableButtons(false)
  234. // setPrimaryButtonText(undefined)
  235. // setSecondaryButtonText(undefined)
  236. }, [claudeAsk, startNewTask])
  237. const handleSecondaryButtonClick = useCallback(() => {
  238. switch (claudeAsk) {
  239. case "api_req_failed":
  240. case "mistake_limit_reached":
  241. startNewTask()
  242. break
  243. case "command":
  244. case "tool":
  245. // responds to the API with a "This operation failed" and lets it try again
  246. vscode.postMessage({ type: "askResponse", askResponse: "noButtonTapped" })
  247. break
  248. }
  249. setTextAreaDisabled(true)
  250. setClaudeAsk(undefined)
  251. setEnableButtons(false)
  252. // setPrimaryButtonText(undefined)
  253. // setSecondaryButtonText(undefined)
  254. }, [claudeAsk, startNewTask])
  255. const handleTaskCloseButtonClick = useCallback(() => {
  256. startNewTask()
  257. }, [startNewTask])
  258. const { selectedModelInfo } = useMemo(() => {
  259. return normalizeApiConfiguration(apiConfiguration)
  260. }, [apiConfiguration])
  261. const selectImages = useCallback(() => {
  262. vscode.postMessage({ type: "selectImages" })
  263. }, [])
  264. const shouldDisableImages =
  265. !selectedModelInfo.supportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
  266. const handleMessage = useCallback(
  267. (e: MessageEvent) => {
  268. const message: ExtensionMessage = e.data
  269. switch (message.type) {
  270. case "action":
  271. switch (message.action!) {
  272. case "didBecomeVisible":
  273. if (!isHidden && !textAreaDisabled && !enableButtons) {
  274. textAreaRef.current?.focus()
  275. }
  276. break
  277. }
  278. break
  279. case "selectedImages":
  280. const newImages = message.images ?? []
  281. if (newImages.length > 0) {
  282. setSelectedImages((prevImages) =>
  283. [...prevImages, ...newImages].slice(0, MAX_IMAGES_PER_MESSAGE)
  284. )
  285. }
  286. break
  287. case "invoke":
  288. switch (message.invoke!) {
  289. case "sendMessage":
  290. handleSendMessage(message.text ?? "", message.images ?? [])
  291. break
  292. case "primaryButtonClick":
  293. handlePrimaryButtonClick()
  294. break
  295. case "secondaryButtonClick":
  296. handleSecondaryButtonClick()
  297. break
  298. }
  299. }
  300. // 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.
  301. },
  302. [
  303. isHidden,
  304. textAreaDisabled,
  305. enableButtons,
  306. handleSendMessage,
  307. handlePrimaryButtonClick,
  308. handleSecondaryButtonClick,
  309. ]
  310. )
  311. useEvent("message", handleMessage)
  312. useMount(() => {
  313. // NOTE: the vscode window needs to be focused for this to work
  314. textAreaRef.current?.focus()
  315. })
  316. useEffect(() => {
  317. const timer = setTimeout(() => {
  318. if (!isHidden && !textAreaDisabled && !enableButtons) {
  319. textAreaRef.current?.focus()
  320. }
  321. }, 50)
  322. return () => {
  323. clearTimeout(timer)
  324. }
  325. }, [isHidden, textAreaDisabled, enableButtons])
  326. const visibleMessages = useMemo(() => {
  327. return modifiedMessages.filter((message) => {
  328. switch (message.ask) {
  329. case "completion_result":
  330. // 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.
  331. if (message.text === "") {
  332. return false
  333. }
  334. break
  335. case "api_req_failed": // this message is used to update the latest api_req_started that the request failed
  336. case "resume_task":
  337. case "resume_completed_task":
  338. return false
  339. }
  340. switch (message.say) {
  341. case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
  342. case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried
  343. return false
  344. case "text":
  345. // 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)
  346. if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
  347. return false
  348. }
  349. break
  350. case "inspect_site_result":
  351. // don't show row for inspect site result until a screenshot is captured
  352. return !!message.images
  353. }
  354. return true
  355. })
  356. }, [modifiedMessages])
  357. const toggleRowExpansion = useCallback(
  358. (ts: number) => {
  359. const isCollapsing = expandedRows[ts] ?? false
  360. const isLast = visibleMessages.at(-1)?.ts === ts
  361. const isSecondToLast = visibleMessages.at(-2)?.ts === ts
  362. const isLastCollapsed = !expandedRows[visibleMessages.at(-1)?.ts ?? 0]
  363. setExpandedRows((prev) => ({
  364. ...prev,
  365. [ts]: !prev[ts],
  366. }))
  367. if (isCollapsing && isAtBottom) {
  368. const timer = setTimeout(() => {
  369. virtuosoRef.current?.scrollToIndex({
  370. index: visibleMessages.length - 1,
  371. align: "end",
  372. })
  373. }, 0)
  374. return () => clearTimeout(timer)
  375. } else if (isLast || isSecondToLast) {
  376. if (isCollapsing) {
  377. if (isSecondToLast && !isLastCollapsed) {
  378. return
  379. }
  380. const timer = setTimeout(() => {
  381. virtuosoRef.current?.scrollToIndex({
  382. index: visibleMessages.length - 1,
  383. align: "end",
  384. })
  385. }, 0)
  386. return () => clearTimeout(timer)
  387. } else {
  388. const timer = setTimeout(() => {
  389. virtuosoRef.current?.scrollToIndex({
  390. index: visibleMessages.length - (isLast ? 1 : 2),
  391. align: "start",
  392. })
  393. }, 0)
  394. return () => clearTimeout(timer)
  395. }
  396. }
  397. },
  398. [isAtBottom, visibleMessages, expandedRows]
  399. )
  400. useEffect(() => {
  401. // dont scroll if we're just updating the api req started informational body
  402. const lastMessage = visibleMessages.at(-1)
  403. const isLastApiReqStarted = lastMessage?.say === "api_req_started"
  404. if (didScrollFromApiReqTs && isLastApiReqStarted && lastMessage?.ts === didScrollFromApiReqTs) {
  405. return
  406. }
  407. // 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.
  408. const timer = setTimeout(() => {
  409. // 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
  410. // NOTE: scroll to bottom may not work if you use margin, see virtuoso's troubleshooting
  411. virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" })
  412. setDidScrollFromApiReqTs(isLastApiReqStarted ? lastMessage?.ts : undefined) // need to do this in timer since this effect can get called a few times simultaneously
  413. }, 50)
  414. return () => clearTimeout(timer)
  415. }, [visibleMessages, didScrollFromApiReqTs])
  416. const placeholderText = useMemo(() => {
  417. const text = task ? "Type a message (@ to add context)..." : "Type your task here (@ to add context)..."
  418. return text
  419. }, [task])
  420. const itemContent = useCallback(
  421. (index: number, message: any) => (
  422. <ChatRow
  423. key={message.ts}
  424. message={message}
  425. isExpanded={expandedRows[message.ts] || false}
  426. onToggleExpand={() => toggleRowExpansion(message.ts)}
  427. lastModifiedMessage={modifiedMessages.at(-1)}
  428. isLast={index === visibleMessages.length - 1}
  429. />
  430. ),
  431. [expandedRows, modifiedMessages, visibleMessages.length, toggleRowExpansion]
  432. )
  433. return (
  434. <div
  435. style={{
  436. position: "fixed",
  437. top: 0,
  438. left: 0,
  439. right: 0,
  440. bottom: 0,
  441. display: isHidden ? "none" : "flex",
  442. flexDirection: "column",
  443. overflow: "hidden",
  444. }}>
  445. {task ? (
  446. <TaskHeader
  447. task={task}
  448. tokensIn={apiMetrics.totalTokensIn}
  449. tokensOut={apiMetrics.totalTokensOut}
  450. doesModelSupportPromptCache={selectedModelInfo.supportsPromptCache}
  451. cacheWrites={apiMetrics.totalCacheWrites}
  452. cacheReads={apiMetrics.totalCacheReads}
  453. totalCost={apiMetrics.totalCost}
  454. onClose={handleTaskCloseButtonClick}
  455. />
  456. ) : (
  457. <div
  458. style={{
  459. flexGrow: 1,
  460. overflowY: "auto",
  461. display: "flex",
  462. flexDirection: "column",
  463. }}>
  464. {showAnnouncement && <Announcement version={version} hideAnnouncement={hideAnnouncement} />}
  465. <div style={{ padding: "0 20px", flexShrink: 0 }}>
  466. <h2>What can I do for you?</h2>
  467. <p>
  468. Thanks to{" "}
  469. <VSCodeLink
  470. href="https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf"
  471. style={{ display: "inline" }}>
  472. Claude 3.5 Sonnet's agentic coding capabilities,
  473. </VSCodeLink>{" "}
  474. I can handle complex software development tasks step-by-step. With tools that let me create
  475. & edit files, explore complex projects, and execute terminal commands (after you grant
  476. permission), I can assist you in ways that go beyond simple code completion or tech support.
  477. </p>
  478. </div>
  479. {taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
  480. </div>
  481. )}
  482. {task && (
  483. <>
  484. <Virtuoso
  485. ref={virtuosoRef}
  486. className="scrollable"
  487. style={{
  488. flexGrow: 1,
  489. overflowY: "scroll", // always show scrollbar
  490. }}
  491. // followOutput={(isAtBottom) => {
  492. // const lastMessage = modifiedMessages.at(-1)
  493. // if (lastMessage && shouldShowChatRow(lastMessage)) {
  494. // return "smooth"
  495. // }
  496. // return false
  497. // }}
  498. // increasing top by 3_000 to prevent jumping around when user collapses a row
  499. 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)
  500. 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
  501. itemContent={itemContent}
  502. atBottomStateChange={setIsAtBottom}
  503. atBottomThreshold={100}
  504. />
  505. <div
  506. style={{
  507. opacity: primaryButtonText || secondaryButtonText ? (enableButtons ? 1 : 0.5) : 0,
  508. display: "flex",
  509. padding: "10px 15px 0px 15px",
  510. }}>
  511. {primaryButtonText && (
  512. <VSCodeButton
  513. appearance="primary"
  514. disabled={!enableButtons}
  515. style={{
  516. flex: secondaryButtonText ? 1 : 2,
  517. marginRight: secondaryButtonText ? "6px" : "0",
  518. }}
  519. onClick={handlePrimaryButtonClick}>
  520. {primaryButtonText}
  521. </VSCodeButton>
  522. )}
  523. {secondaryButtonText && (
  524. <VSCodeButton
  525. appearance="secondary"
  526. disabled={!enableButtons}
  527. style={{ flex: 1, marginLeft: "6px" }}
  528. onClick={handleSecondaryButtonClick}>
  529. {secondaryButtonText}
  530. </VSCodeButton>
  531. )}
  532. </div>
  533. </>
  534. )}
  535. <ChatTextArea
  536. ref={textAreaRef}
  537. inputValue={inputValue}
  538. setInputValue={setInputValue}
  539. textAreaDisabled={textAreaDisabled}
  540. placeholderText={placeholderText}
  541. selectedImages={selectedImages}
  542. setSelectedImages={setSelectedImages}
  543. onSend={() => handleSendMessage(inputValue, selectedImages)}
  544. onSelectImages={selectImages}
  545. shouldDisableImages={shouldDisableImages}
  546. onHeightChange={() => {
  547. if (isAtBottom) {
  548. //virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "auto" })
  549. virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" })
  550. }
  551. }}
  552. />
  553. </div>
  554. )
  555. }
  556. export default ChatView