Files
Roo-Code/webview-ui/src/components/chat/ChatView.tsx
Saoud Rizwan e50edd0767 Fixes
2024-10-09 01:49:57 -04:00

750 lines
26 KiB
TypeScript

import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useEvent, useMount } from "react-use"
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
import styled from "styled-components"
import { ClaudeAsk, ClaudeSayTool, ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { findLast } from "../../../../src/shared/array"
import { combineApiRequests } from "../../../../src/shared/combineApiRequests"
import { combineCommandSequences } from "../../../../src/shared/combineCommandSequences"
import { getApiMetrics } from "../../../../src/shared/getApiMetrics"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import HistoryPreview from "../history/HistoryPreview"
import { normalizeApiConfiguration } from "../settings/ApiOptions"
import Announcement from "./Announcement"
import ChatRow from "./ChatRow"
import ChatTextArea from "./ChatTextArea"
import TaskHeader from "./TaskHeader"
interface ChatViewProps {
isHidden: boolean
showAnnouncement: boolean
hideAnnouncement: () => void
showHistoryView: () => void
}
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const { version, claudeMessages: messages, taskHistory, apiConfiguration } = useExtensionState()
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // 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)
const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
// has to be after api_req_finished are all reduced into api_req_started messages
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
const [inputValue, setInputValue] = useState("")
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const [textAreaDisabled, setTextAreaDisabled] = useState(false)
const [selectedImages, setSelectedImages] = useState<string[]>([])
// 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)
const [claudeAsk, setClaudeAsk] = useState<ClaudeAsk | undefined>(undefined)
const [enableButtons, setEnableButtons] = useState<boolean>(false)
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const isAtBottomRef = useRef(false)
const [didScrollUp, setDidScrollUp] = useState(false)
const lastScrollTopRef = useRef(0)
const [didClickScrollToBottom, setDidClickScrollToBottom] = useState(false)
const [didClickCancel, setDidClickCancel] = useState(false)
useEffect(() => {
// if last message is an ask, show user ask UI
// 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.
// basically as long as a task is active, the conversation history will be persisted
const lastMessage = messages.at(-1)
if (lastMessage) {
switch (lastMessage.type) {
case "ask":
const isPartial = lastMessage.partial === true
switch (lastMessage.ask) {
case "api_req_failed":
setTextAreaDisabled(true)
setClaudeAsk("api_req_failed")
setEnableButtons(true)
setPrimaryButtonText("Retry")
setSecondaryButtonText("Start New Task")
break
case "mistake_limit_reached":
setTextAreaDisabled(false)
setClaudeAsk("mistake_limit_reached")
setEnableButtons(true)
setPrimaryButtonText("Proceed Anyways")
setSecondaryButtonText("Start New Task")
break
case "followup":
setTextAreaDisabled(isPartial)
setClaudeAsk("followup")
setEnableButtons(isPartial)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
break
case "tool":
setTextAreaDisabled(isPartial)
setClaudeAsk("tool")
setEnableButtons(!isPartial)
const tool = JSON.parse(lastMessage.text || "{}") as ClaudeSayTool
switch (tool.tool) {
case "editedExistingFile":
case "newFileCreated":
setPrimaryButtonText("Save")
setSecondaryButtonText("Reject")
break
default:
setPrimaryButtonText("Approve")
setSecondaryButtonText("Reject")
break
}
break
case "command":
setTextAreaDisabled(isPartial)
setClaudeAsk("command")
setEnableButtons(!isPartial)
setPrimaryButtonText("Run Command")
setSecondaryButtonText("Reject")
break
case "command_output":
setTextAreaDisabled(false)
setClaudeAsk("command_output")
setEnableButtons(true)
setPrimaryButtonText("Proceed While Running")
setSecondaryButtonText(undefined)
break
case "completion_result":
// extension waiting for feedback. but we can just present a new task button
setTextAreaDisabled(isPartial)
setClaudeAsk("completion_result")
setEnableButtons(!isPartial)
setPrimaryButtonText("Start New Task")
setSecondaryButtonText(undefined)
break
case "resume_task":
setTextAreaDisabled(false)
setClaudeAsk("resume_task")
setEnableButtons(true)
setPrimaryButtonText("Resume Task")
setSecondaryButtonText(undefined)
setDidClickCancel(false) // special case where we reset the cancel button state
break
case "resume_completed_task":
setTextAreaDisabled(false)
setClaudeAsk("resume_completed_task")
setEnableButtons(true)
setPrimaryButtonText("Start New Task")
setSecondaryButtonText(undefined)
setDidClickCancel(false)
break
}
break
case "say":
// don't want to reset since there could be a "say" after an "ask" while ask is waiting for response
switch (lastMessage.say) {
case "api_req_started":
if (messages.at(-2)?.ask === "command_output") {
// 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)
setInputValue("")
setTextAreaDisabled(true)
setSelectedImages([])
setClaudeAsk(undefined)
setEnableButtons(false)
}
break
case "task":
case "error":
case "api_req_finished":
case "text":
case "inspect_site_result":
case "command_output":
case "completion_result":
case "tool":
break
}
break
}
} else {
// this would get called after sending the first message, so we have to watch messages.length instead
// No messages, so user has to submit a task
// setTextAreaDisabled(false)
// setClaudeAsk(undefined)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
}
}, [messages])
useEffect(() => {
if (messages.length === 0) {
setTextAreaDisabled(false)
setClaudeAsk(undefined)
setEnableButtons(false)
setPrimaryButtonText(undefined)
setSecondaryButtonText(undefined)
}
}, [messages.length])
const isStreaming = useMemo(() => {
const isToolCurrentlyAsking = claudeAsk !== undefined && enableButtons && primaryButtonText !== undefined
if (isToolCurrentlyAsking) {
return false
}
const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true
if (isLastMessagePartial) {
return true
} else {
const lastApiReqStarted = findLast(modifiedMessages, (message) => message.say === "api_req_started")
if (lastApiReqStarted && lastApiReqStarted.text != null && lastApiReqStarted.say === "api_req_started") {
const cost = JSON.parse(lastApiReqStarted.text).cost
if (cost === undefined) {
// api request has not finished yet
return true
}
}
}
return false
}, [modifiedMessages, claudeAsk, enableButtons, primaryButtonText])
const handleSendMessage = useCallback(
(text: string, images: string[]) => {
text = text.trim()
if (text || images.length > 0) {
if (messages.length === 0) {
vscode.postMessage({ type: "newTask", text, images })
} else if (claudeAsk) {
switch (claudeAsk) {
case "followup":
case "tool":
case "command": // user can provide feedback to a tool or command use
case "command_output": // user can send input to command stdin
case "completion_result": // if this happens then the user has feedback for the completion result
case "resume_task":
case "resume_completed_task":
case "mistake_limit_reached":
vscode.postMessage({
type: "askResponse",
askResponse: "messageResponse",
text,
images,
})
break
// there is no other case that a textfield should be enabled
}
}
setInputValue("")
setTextAreaDisabled(true)
setSelectedImages([])
setClaudeAsk(undefined)
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
}
},
[messages.length, claudeAsk]
)
const startNewTask = useCallback(() => {
vscode.postMessage({ type: "clearTask" })
}, [])
/*
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.
*/
const handlePrimaryButtonClick = useCallback(() => {
switch (claudeAsk) {
case "api_req_failed":
case "command":
case "command_output":
case "tool":
case "resume_task":
case "mistake_limit_reached":
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonTapped" })
break
case "completion_result":
case "resume_completed_task":
// extension waiting for feedback. but we can just present a new task button
startNewTask()
break
}
setTextAreaDisabled(true)
setClaudeAsk(undefined)
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
}, [claudeAsk, startNewTask])
const handleSecondaryButtonClick = useCallback(() => {
if (isStreaming) {
vscode.postMessage({ type: "cancelTask" })
setDidClickCancel(true)
return
}
switch (claudeAsk) {
case "api_req_failed":
case "mistake_limit_reached":
startNewTask()
break
case "command":
case "tool":
// responds to the API with a "This operation failed" and lets it try again
vscode.postMessage({ type: "askResponse", askResponse: "noButtonTapped" })
break
}
setTextAreaDisabled(true)
setClaudeAsk(undefined)
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
}, [claudeAsk, startNewTask, isStreaming])
const handleTaskCloseButtonClick = useCallback(() => {
startNewTask()
}, [startNewTask])
const { selectedModelInfo } = useMemo(() => {
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
const selectImages = useCallback(() => {
vscode.postMessage({ type: "selectImages" })
}, [])
const shouldDisableImages =
!selectedModelInfo.supportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
const handleMessage = useCallback(
(e: MessageEvent) => {
const message: ExtensionMessage = e.data
switch (message.type) {
case "action":
switch (message.action!) {
case "didBecomeVisible":
if (!isHidden && !textAreaDisabled && !enableButtons) {
textAreaRef.current?.focus()
}
break
}
break
case "selectedImages":
const newImages = message.images ?? []
if (newImages.length > 0) {
setSelectedImages((prevImages) =>
[...prevImages, ...newImages].slice(0, MAX_IMAGES_PER_MESSAGE)
)
}
break
case "invoke":
switch (message.invoke!) {
case "sendMessage":
handleSendMessage(message.text ?? "", message.images ?? [])
break
case "primaryButtonClick":
handlePrimaryButtonClick()
break
case "secondaryButtonClick":
handleSecondaryButtonClick()
break
}
}
// 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.
},
[
isHidden,
textAreaDisabled,
enableButtons,
handleSendMessage,
handlePrimaryButtonClick,
handleSecondaryButtonClick,
]
)
useEvent("message", handleMessage)
useMount(() => {
// NOTE: the vscode window needs to be focused for this to work
textAreaRef.current?.focus()
})
useEffect(() => {
const timer = setTimeout(() => {
if (!isHidden && !textAreaDisabled && !enableButtons) {
textAreaRef.current?.focus()
}
}, 50)
return () => {
clearTimeout(timer)
}
}, [isHidden, textAreaDisabled, enableButtons])
const visibleMessages = useMemo(() => {
return modifiedMessages.filter((message) => {
switch (message.ask) {
case "completion_result":
// 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.
if (message.text === "") {
return false
}
break
case "api_req_failed": // this message is used to update the latest api_req_started that the request failed
case "resume_task":
case "resume_completed_task":
return false
}
switch (message.say) {
case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried
return false
case "text":
// 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)
if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
return false
}
break
case "inspect_site_result":
// don't show row for inspect site result until a screenshot is captured
return !!message.images
}
return true
})
}, [modifiedMessages])
// scrolling
const scrollToBottomSmooth = useMemo(
() =>
debounce(
() => {
virtuosoRef.current?.scrollTo({
top: Number.MAX_SAFE_INTEGER,
behavior: "smooth",
})
},
10,
{ immediate: true }
),
[]
)
const scrollToBottomAuto = useCallback(() => {
virtuosoRef.current?.scrollTo({
top: Number.MAX_SAFE_INTEGER,
behavior: "auto", // instant causes crash
})
}, [])
// scroll when user toggles certain rows
const toggleRowExpansion = useCallback(
(ts: number) => {
const isCollapsing = expandedRows[ts] ?? false
const isLast = visibleMessages.at(-1)?.ts === ts
const isSecondToLast = visibleMessages.at(-2)?.ts === ts
const isLastCollapsedApiReq =
visibleMessages.at(-1)?.say === "api_req_started" && !expandedRows[visibleMessages.at(-1)?.ts ?? 0]
setExpandedRows((prev) => ({
...prev,
[ts]: !prev[ts],
}))
if (isCollapsing && isAtBottomRef.current) {
const timer = setTimeout(() => {
scrollToBottomAuto()
}, 0)
return () => clearTimeout(timer)
} else if (isLast || isSecondToLast) {
if (isCollapsing) {
if (isSecondToLast && !isLastCollapsedApiReq) {
return
}
const timer = setTimeout(() => {
scrollToBottomAuto()
}, 0)
return () => clearTimeout(timer)
} else {
const timer = setTimeout(() => {
virtuosoRef.current?.scrollToIndex({
index: visibleMessages.length - (isLast ? 1 : 2),
align: "start",
})
}, 0)
return () => clearTimeout(timer)
}
}
},
[visibleMessages, expandedRows, scrollToBottomAuto]
)
const handleRowHeightChange = useCallback(() => {
if (isAtBottomRef.current || didClickScrollToBottom || !didScrollUp) {
scrollToBottomSmooth()
}
}, [scrollToBottomSmooth, didClickScrollToBottom, didScrollUp])
/*
- didScrollUp lets us know if the user scrolled up, so we don't auto scroll down anymore during stream
- this variable is important to make sure we don't show the scroll to bottom button during streaming since isAtBottom gets set to false if the streamed in content is taller than the bottom threshold.
- didClickScrollToBottom is used to keep scrolling down when last row height changes, as isAtBottom may not update fast enough during stream
- we use the following scroll listener to detect that the current scroll point is less than the last scroll point, and in atBottomStateChange we set this back to false if isAtBottom. This way we can know when to show the scroll to bottom button.
- interestingly followoutput would scroll even if the isAtBottom param was false or wrong, so if didScrollUp we don't followoutput to mitigate that issue
*/
const handleScroll = useCallback((e: React.UIEvent<HTMLElement>) => {
const currentScrollTop = e.currentTarget.scrollTop
if (currentScrollTop < lastScrollTopRef.current) {
setDidScrollUp(true)
}
lastScrollTopRef.current = currentScrollTop
}, [])
useEffect(() => {
const lastMessage = messages.at(-1)
if (lastMessage) {
switch (lastMessage.say) {
case "user_feedback": {
// when sending a message user should be scrolled to the bottom (this also addresses a bug where sometimes when sending a message virtuoso would jump upwards, possibly due to textarea changing in size)
const timer = setTimeout(() => {
scrollToBottomSmooth()
}, 0)
return () => clearTimeout(timer)
}
case "api_req_retried": {
// unique case where the api_req_started row will shrink in size when scrollview at bottom, causing didScrollUp to get set to true and followoutput to break. To mitigate this when an api request is retried, it's safe to assume they're already scrolled to the bottom
const timer = setTimeout(() => {
scrollToBottomAuto()
}, 50)
return () => clearTimeout(timer)
}
default:
break
}
}
}, [messages, scrollToBottomAuto, scrollToBottomSmooth])
const placeholderText = useMemo(() => {
const text = task ? "Type a message (@ to add context)..." : "Type your task here (@ to add context)..."
return text
}, [task])
const itemContent = useCallback(
(index: number, message: any) => (
<ChatRow
key={message.ts}
message={message}
isExpanded={expandedRows[message.ts] || false}
onToggleExpand={() => toggleRowExpansion(message.ts)}
lastModifiedMessage={modifiedMessages.at(-1)}
isLast={index === visibleMessages.length - 1}
onHeightChange={handleRowHeightChange}
/>
),
[expandedRows, modifiedMessages, visibleMessages.length, toggleRowExpansion, handleRowHeightChange]
)
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: isHidden ? "none" : "flex",
flexDirection: "column",
overflow: "hidden",
}}>
{task ? (
<TaskHeader
task={task}
tokensIn={apiMetrics.totalTokensIn}
tokensOut={apiMetrics.totalTokensOut}
doesModelSupportPromptCache={selectedModelInfo.supportsPromptCache}
cacheWrites={apiMetrics.totalCacheWrites}
cacheReads={apiMetrics.totalCacheReads}
totalCost={apiMetrics.totalCost}
onClose={handleTaskCloseButtonClick}
/>
) : (
<div
style={{
flexGrow: 1,
overflowY: "auto",
display: "flex",
flexDirection: "column",
}}>
{showAnnouncement && <Announcement version={version} hideAnnouncement={hideAnnouncement} />}
<div style={{ padding: "0 20px", flexShrink: 0 }}>
<h2>What can I do for you?</h2>
<p>
Thanks to{" "}
<VSCodeLink
href="https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf"
style={{ display: "inline" }}>
Claude 3.5 Sonnet's agentic coding capabilities,
</VSCodeLink>{" "}
I can handle complex software development tasks step-by-step. With tools that let me create
& edit files, explore complex projects, and execute terminal commands (after you grant
permission), I can assist you in ways that go beyond simple code completion or tech support.
</p>
</div>
{taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
</div>
)}
{task && (
<>
<Virtuoso
ref={virtuosoRef}
key={task.ts} // trick to make sure virtuoso re-renders when task changes, and we use initialTopMostItemIndex to start at the bottom
className="scrollable"
style={{
flexGrow: 1,
overflowY: "scroll", // always show scrollbar
}}
// followOutput works much more reliably than manually tracking visible messages count. This will scroll when the count changes, so for cases where the row height changes, we use onHeightChange to scroll.
followOutput={(isAtBottom: boolean) => {
if (isAtBottom) {
return "smooth"
}
if (didScrollUp) {
// would sometimes get set to true even when new items get added. followoutput taking us to bottom will set this back to false
return false
} else {
return "smooth"
}
// if (isAtBottom) {
// return "smooth" // can be 'auto' or false to avoid scrolling
// } else {
// return false
// }
}}
components={{
Footer: () => <div style={{ height: 5 }} />, // Add empty padding at the bottom
}}
// followOutput={(isAtBottom) => {
// const lastMessage = modifiedMessages.at(-1)
// if (lastMessage && shouldShowChatRow(lastMessage)) {
// return "smooth"
// }
// return false
// }}
// increasing top by 3_000 to prevent jumping around when user collapses a row
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)
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
itemContent={itemContent}
atBottomStateChange={(isAtBottom) => {
isAtBottomRef.current = isAtBottom
// setShowScrollToBottom(!value)
if (isAtBottom) {
setDidScrollUp(false)
setDidClickScrollToBottom(false) // reset for next time user clicks button
}
setShowScrollToBottom(didScrollUp && !isAtBottom)
}}
atBottomThreshold={10} // anything lower causes issues with followOutput
onScroll={handleScroll}
initialTopMostItemIndex={visibleMessages.length - 1}
/>
{showScrollToBottom ? (
<div
style={{
display: "flex",
padding: "10px 15px 0px 15px",
}}>
<ScrollToBottomButton
onClick={() => {
scrollToBottomSmooth()
setDidClickScrollToBottom(true)
}}>
<span className="codicon codicon-chevron-down" style={{ fontSize: "18px" }}></span>
</ScrollToBottomButton>
</div>
) : (
<div
style={{
opacity:
primaryButtonText || secondaryButtonText || isStreaming
? enableButtons || (isStreaming && !didClickCancel)
? 1
: 0.5
: 0,
display: "flex",
padding: "10px 15px 0px 15px",
}}>
{primaryButtonText && !isStreaming && (
<VSCodeButton
appearance="primary"
disabled={!enableButtons}
style={{
flex: secondaryButtonText ? 1 : 2,
marginRight: secondaryButtonText ? "6px" : "0",
}}
onClick={handlePrimaryButtonClick}>
{primaryButtonText}
</VSCodeButton>
)}
{(secondaryButtonText || isStreaming) && (
<VSCodeButton
appearance="secondary"
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
style={{
flex: isStreaming ? 2 : 1,
marginLeft: isStreaming ? 0 : "6px",
}}
onClick={handleSecondaryButtonClick}>
{isStreaming ? "Cancel" : secondaryButtonText}
</VSCodeButton>
)}
</div>
)}
</>
)}
<ChatTextArea
ref={textAreaRef}
inputValue={inputValue}
setInputValue={setInputValue}
textAreaDisabled={textAreaDisabled}
placeholderText={placeholderText}
selectedImages={selectedImages}
setSelectedImages={setSelectedImages}
onSend={() => handleSendMessage(inputValue, selectedImages)}
onSelectImages={selectImages}
shouldDisableImages={shouldDisableImages}
onHeightChange={() => {
if (isAtBottomRef.current) {
scrollToBottomAuto()
}
}}
/>
</div>
)
}
const ScrollToBottomButton = styled.div`
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent);
border-radius: 3px;
overflow: hidden;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
flex: 1;
height: 25px;
&:hover {
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 90%, transparent);
}
&:active {
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 70%, transparent);
}
`
export default ChatView