mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-21 04:41:16 -05:00
Refactor web components
This commit is contained in:
132
webview-ui/src/components/chat/Announcement.tsx
Normal file
132
webview-ui/src/components/chat/Announcement.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { memo } from "react"
|
||||
// import VSCodeButtonLink from "./VSCodeButtonLink"
|
||||
// import { getOpenRouterAuthUrl } from "./ApiOptions"
|
||||
// import { vscode } from "../utils/vscode"
|
||||
|
||||
interface AnnouncementProps {
|
||||
version: string
|
||||
hideAnnouncement: () => void
|
||||
}
|
||||
/*
|
||||
You must update the latestAnnouncementId in ClaudeDevProvider for new announcements to show to users. This new id will be compared with whats in state for the 'last announcement shown', and if it's different then the announcement will render. As soon as an announcement is shown, the id will be updated in state. This ensures that announcements are not shown more than once, even if the user doesn't close it themselves.
|
||||
*/
|
||||
const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--vscode-editor-inactiveSelectionBackground)",
|
||||
borderRadius: "3px",
|
||||
padding: "12px 16px",
|
||||
margin: "5px 15px 5px 15px",
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={hideAnnouncement}
|
||||
style={{ position: "absolute", top: "8px", right: "8px" }}>
|
||||
<span className="codicon codicon-close"></span>
|
||||
</VSCodeButton>
|
||||
<h3 style={{ margin: "0 0 8px" }}>
|
||||
🎉{" "}New in v{version}
|
||||
</h3>
|
||||
<p style={{ margin: "5px 0px" }}></p>
|
||||
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
Claude can now use a browser! This update adds a new <code>inspect_site</code> tool that captures
|
||||
screenshots and console logs from websites (including localhost), making it easier for Claude to
|
||||
troubleshoot issues on his own.
|
||||
<VSCodeLink style={{ display: "inline" }} href="https://x.com/sdrzn/status/1837559914023342129">
|
||||
See a demo here.
|
||||
</VSCodeLink>
|
||||
</li>
|
||||
<li>
|
||||
Improved automatic linter/compiler debugging by only sending Claude new errors that result from his
|
||||
edits, rather than reporting all workspace problems.
|
||||
</li>
|
||||
<li>
|
||||
You can now use '@' in the textarea to add context:
|
||||
<ul style={{ margin: "0 0 8px", paddingLeft: "20px" }}>
|
||||
<li>
|
||||
<strong>@url:</strong> Paste in a URL for the extension to fetch and convert to markdown
|
||||
(i.e. give Claude the latest docs)
|
||||
</li>
|
||||
<li>
|
||||
<strong>@problems:</strong> Add workspace errors and warnings for Claude to fix
|
||||
</li>
|
||||
<li>
|
||||
<strong>@file:</strong> Adds a file's contents so you don't have to waste API requests
|
||||
approving read file (+ type to search files)
|
||||
</li>
|
||||
<li>
|
||||
<strong>@folder:</strong> Adds folder's files all at once
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
{/* <p style={{ margin: "5px 0px" }}>
|
||||
Claude can now monitor workspace problems to keep updated on linter/compiler/build issues, letting him
|
||||
proactively fix errors on his own! (adding missing imports, fixing type errors, etc.)
|
||||
<VSCodeLink style={{ display: "inline" }} href="https://x.com/sdrzn/status/1835100787275419829">
|
||||
See a demo here.
|
||||
</VSCodeLink>
|
||||
</p> */}
|
||||
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
|
||||
so I recommend trying them out.
|
||||
<br />
|
||||
{!apiConfiguration?.openRouterApiKey && (
|
||||
<VSCodeButtonLink
|
||||
href={getOpenRouterAuthUrl(vscodeUriScheme)}
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
margin: "4px -30px 2px 0",
|
||||
}}>
|
||||
Get OpenRouter API Key
|
||||
</VSCodeButtonLink>
|
||||
)}
|
||||
{apiConfiguration?.openRouterApiKey && apiConfiguration?.apiProvider !== "openrouter" && (
|
||||
<VSCodeButton
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "apiConfiguration",
|
||||
apiConfiguration: { ...apiConfiguration, apiProvider: "openrouter" },
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
margin: "4px -30px 2px 0",
|
||||
}}>
|
||||
Switch to OpenRouter
|
||||
</VSCodeButton>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<b>Edit Claude's changes before accepting!</b> When he creates or edits a file, you can modify his
|
||||
changes directly in the right side of the diff view (+ hover over the 'Revert Block' arrow button in
|
||||
the center to undo "<code>{"// rest of code here"}</code>" shenanigans)
|
||||
</li>
|
||||
<li>
|
||||
New <code>search_files</code> tool that lets Claude perform regex searches in your project, letting
|
||||
him refactor code, address TODOs and FIXMEs, remove dead code, and more!
|
||||
</li>
|
||||
<li>
|
||||
When Claude runs commands, you can now type directly in the terminal (+ support for Python
|
||||
environments)
|
||||
</li>
|
||||
</ul>*/}
|
||||
<p style={{ margin: "0" }}>
|
||||
Follow me for more updates!{" "}
|
||||
<VSCodeLink href="https://x.com/sdrzn" style={{ display: "inline" }}>
|
||||
@sdrzn
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Announcement)
|
||||
853
webview-ui/src/components/chat/ChatRow.tsx
Normal file
853
webview-ui/src/components/chat/ChatRow.tsx
Normal file
@@ -0,0 +1,853 @@
|
||||
import { VSCodeBadge, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import React, { memo, useMemo } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { ClaudeMessage, ClaudeSayTool } from "../../../../src/shared/ExtensionMessage"
|
||||
import { COMMAND_OUTPUT_STRING } from "../../../../src/shared/combineCommandSequences"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
|
||||
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import { highlightMentions } from "./TaskHeader"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
|
||||
interface ChatRowProps {
|
||||
message: ClaudeMessage
|
||||
isExpanded: boolean
|
||||
onToggleExpand: () => void
|
||||
lastModifiedMessage?: ClaudeMessage
|
||||
isLast: boolean
|
||||
}
|
||||
|
||||
const ChatRow = memo(
|
||||
(props: ChatRowProps) => {
|
||||
// we cannot return null as virtuoso does not support it, so we use a separate visibleMessages array to filter out messages that should not be rendered
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 6px 10px 15px",
|
||||
}}>
|
||||
<ChatRowContent {...props} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// memo does shallow comparison of props, so we need to do deep comparison of arrays/objects whose properties might change
|
||||
deepEqual
|
||||
)
|
||||
|
||||
export default ChatRow
|
||||
|
||||
const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowProps) => {
|
||||
const cost = useMemo(() => {
|
||||
if (message.text != null && message.say === "api_req_started") {
|
||||
return JSON.parse(message.text).cost
|
||||
}
|
||||
return undefined
|
||||
}, [message.text, message.say])
|
||||
const apiRequestFailedMessage =
|
||||
isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried
|
||||
? lastModifiedMessage?.text
|
||||
: undefined
|
||||
const isCommandExecuting =
|
||||
isLast && lastModifiedMessage?.ask === "command" && lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING)
|
||||
const type = message.type === "ask" ? message.ask : message.say
|
||||
|
||||
const normalColor = "var(--vscode-foreground)"
|
||||
const errorColor = "var(--vscode-errorForeground)"
|
||||
const successColor = "var(--vscode-charts-green)"
|
||||
|
||||
const [icon, title] = useMemo(() => {
|
||||
switch (type) {
|
||||
case "error":
|
||||
return [
|
||||
<span
|
||||
className="codicon codicon-error"
|
||||
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>,
|
||||
<span style={{ color: errorColor, fontWeight: "bold" }}>Error</span>,
|
||||
]
|
||||
case "mistake_limit_reached":
|
||||
return [
|
||||
<span
|
||||
className="codicon codicon-error"
|
||||
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>,
|
||||
<span style={{ color: errorColor, fontWeight: "bold" }}>Claude is having trouble...</span>,
|
||||
]
|
||||
case "command":
|
||||
return [
|
||||
isCommandExecuting ? (
|
||||
<ProgressIndicator />
|
||||
) : (
|
||||
<span
|
||||
className="codicon codicon-terminal"
|
||||
style={{ color: normalColor, marginBottom: "-1.5px" }}></span>
|
||||
),
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>
|
||||
Claude wants to execute this command:
|
||||
</span>,
|
||||
]
|
||||
case "completion_result":
|
||||
return [
|
||||
<span
|
||||
className="codicon codicon-check"
|
||||
style={{ color: successColor, marginBottom: "-1.5px" }}></span>,
|
||||
<span style={{ color: successColor, fontWeight: "bold" }}>Task Completed</span>,
|
||||
]
|
||||
case "api_req_started":
|
||||
return [
|
||||
cost != null ? (
|
||||
<span
|
||||
className="codicon codicon-check"
|
||||
style={{ color: successColor, marginBottom: "-1.5px" }}></span>
|
||||
) : apiRequestFailedMessage ? (
|
||||
<span
|
||||
className="codicon codicon-error"
|
||||
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>
|
||||
) : (
|
||||
<ProgressIndicator />
|
||||
),
|
||||
cost != null ? (
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>API Request</span>
|
||||
) : apiRequestFailedMessage ? (
|
||||
<span style={{ color: errorColor, fontWeight: "bold" }}>API Request Failed</span>
|
||||
) : (
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>API Request...</span>
|
||||
),
|
||||
]
|
||||
case "followup":
|
||||
return [
|
||||
<span
|
||||
className="codicon codicon-question"
|
||||
style={{ color: normalColor, marginBottom: "-1.5px" }}></span>,
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>Claude has a question:</span>,
|
||||
]
|
||||
default:
|
||||
return [null, null]
|
||||
}
|
||||
}, [type, cost, apiRequestFailedMessage, isCommandExecuting])
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
marginBottom: "10px",
|
||||
}
|
||||
|
||||
const pStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}
|
||||
|
||||
const tool = useMemo(() => {
|
||||
if (message.ask === "tool" || message.say === "tool") {
|
||||
return JSON.parse(message.text || "{}") as ClaudeSayTool
|
||||
}
|
||||
return null
|
||||
}, [message.ask, message.say, message.text])
|
||||
|
||||
if (tool) {
|
||||
const toolIcon = (name: string) => (
|
||||
<span
|
||||
className={`codicon codicon-${name}`}
|
||||
style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
|
||||
)
|
||||
|
||||
switch (tool.tool) {
|
||||
case "editedExistingFile":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("edit")}
|
||||
<span style={{ fontWeight: "bold" }}>Claude wants to edit this file:</span>
|
||||
</div>
|
||||
<CodeAccordian
|
||||
diff={tool.diff!}
|
||||
path={tool.path!}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "newFileCreated":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("new-file")}
|
||||
<span style={{ fontWeight: "bold" }}>Claude wants to create a new file:</span>
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "readFile":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("file-code")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{message.type === "ask" ? "Claude wants to read this file:" : "Claude read this file:"}
|
||||
</span>
|
||||
</div>
|
||||
{/* <CodeAccordian
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/> */}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
backgroundColor: CODE_BLOCK_BG_COLOR,
|
||||
overflow: "hidden",
|
||||
border: "1px solid var(--vscode-editorGroup-border)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "9px 10px",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({ type: "openFile", text: tool.content })
|
||||
}}>
|
||||
{tool.path?.startsWith(".") && <span>.</span>}
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
marginRight: "8px",
|
||||
direction: "rtl",
|
||||
textAlign: "left",
|
||||
}}>
|
||||
{removeLeadingNonAlphanumeric(tool.path ?? "") + "\u200E"}
|
||||
</span>
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
<span
|
||||
className={`codicon codicon-link-external`}
|
||||
style={{ fontSize: 13.5, margin: "1px 0" }}></span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "listFilesTopLevel":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("folder-opened")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{message.type === "ask"
|
||||
? "Claude wants to view the top level files in this directory:"
|
||||
: "Claude viewed the top level files in this directory:"}
|
||||
</span>
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
language="shell-session"
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "listFilesRecursive":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("folder-opened")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{message.type === "ask"
|
||||
? "Claude wants to recursively view all files in this directory:"
|
||||
: "Claude recursively viewed all files in this directory:"}
|
||||
</span>
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
language="shell-session"
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "listCodeDefinitionNames":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("file-code")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{message.type === "ask"
|
||||
? "Claude wants to view source code definition names used in this directory:"
|
||||
: "Claude viewed source code definition names used in this directory:"}
|
||||
</span>
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "searchFiles":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("search")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{message.type === "ask" ? (
|
||||
<>
|
||||
Claude wants to search this directory for <code>{tool.regex}</code>:
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Claude searched this directory for <code>{tool.regex}</code>:
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={tool.content!}
|
||||
path={tool.path! + (tool.filePattern ? `/(${tool.filePattern})` : "")}
|
||||
language="plaintext"
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "inspectSite":
|
||||
const isInspecting = lastModifiedMessage?.say === "inspect_site_result" && !lastModifiedMessage?.images
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{isInspecting ? <ProgressIndicator /> : toolIcon("inspect")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{message.type === "ask" ? (
|
||||
<>Claude wants to inspect this website:</>
|
||||
) : (
|
||||
<>Claude is inspecting this website:</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
border: "1px solid var(--vscode-editorGroup-border)",
|
||||
overflow: "hidden",
|
||||
backgroundColor: CODE_BLOCK_BG_COLOR,
|
||||
}}>
|
||||
<CodeBlock source={`${"```"}shell\n${tool.path}\n${"```"}`} forceWrap={true} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "say":
|
||||
switch (message.say) {
|
||||
case "api_req_started":
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
...headerStyle,
|
||||
marginBottom: cost == null && apiRequestFailedMessage ? 10 : 0,
|
||||
justifyContent: "space-between",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
}}
|
||||
onClick={onToggleExpand}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
{icon}
|
||||
{title}
|
||||
{cost != null && cost > 0 && <VSCodeBadge>${Number(cost)?.toFixed(4)}</VSCodeBadge>}
|
||||
</div>
|
||||
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
|
||||
</div>
|
||||
{cost == null && apiRequestFailedMessage && (
|
||||
<>
|
||||
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>
|
||||
{apiRequestFailedMessage}
|
||||
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
It seems like you're having Windows PowerShell issues, please see this{" "}
|
||||
<a
|
||||
href="https://github.com/saoudrizwan/claude-dev/wiki/TroubleShooting-%E2%80%90-%22PowerShell-is-not-recognized-as-an-internal-or-external-command%22"
|
||||
style={{ color: "inherit", textDecoration: "underline" }}>
|
||||
troubleshooting guide
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* {apiProvider === "kodu" && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent)",
|
||||
color: "var(--vscode-editor-foreground)",
|
||||
padding: "6px 8px",
|
||||
borderRadius: "3px",
|
||||
margin: "10px 0 0 0",
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-warning"
|
||||
style={{
|
||||
marginRight: 6,
|
||||
fontSize: 16,
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}></i>
|
||||
<span>
|
||||
Uh-oh, this could be a problem on Kodu's end. We've been alerted and
|
||||
will resolve this ASAP. You can also{" "}
|
||||
<a
|
||||
href="https://discord.gg/claudedev"
|
||||
style={{ color: "inherit", textDecoration: "underline" }}>
|
||||
contact us on discord
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<CodeAccordian
|
||||
code={JSON.parse(message.text || "{}").request}
|
||||
language="markdown"
|
||||
isExpanded={true}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
case "api_req_finished":
|
||||
return null // we should never see this message type
|
||||
case "text":
|
||||
return (
|
||||
<div>
|
||||
<Markdown markdown={message.text} />
|
||||
</div>
|
||||
)
|
||||
case "user_feedback":
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--vscode-badge-background)",
|
||||
color: "var(--vscode-badge-foreground)",
|
||||
borderRadius: "3px",
|
||||
padding: "9px",
|
||||
whiteSpace: "pre-line",
|
||||
wordWrap: "break-word",
|
||||
}}>
|
||||
<span style={{ display: "block" }}>{highlightMentions(message.text)}</span>
|
||||
{message.images && message.images.length > 0 && (
|
||||
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case "user_feedback_diff":
|
||||
const tool = JSON.parse(message.text || "{}") as ClaudeSayTool
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: -10,
|
||||
width: "100%",
|
||||
}}>
|
||||
<CodeAccordian
|
||||
diff={tool.diff!}
|
||||
isFeedback={true}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "inspect_site_result":
|
||||
const logs = message.text || ""
|
||||
const screenshot = message.images?.[0]
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: -10,
|
||||
width: "100%",
|
||||
}}>
|
||||
{screenshot && (
|
||||
<img
|
||||
src={screenshot}
|
||||
alt="Inspect screenshot"
|
||||
style={{
|
||||
width: "calc(100% - 2px)",
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
marginBottom: logs ? 7 : 0,
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
marginLeft: "1px",
|
||||
}}
|
||||
onClick={() => vscode.postMessage({ type: "openImage", text: screenshot })}
|
||||
/>
|
||||
)}
|
||||
{logs && (
|
||||
<CodeAccordian
|
||||
code={logs}
|
||||
language="shell"
|
||||
isConsoleLogs={true}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case "error":
|
||||
return (
|
||||
<>
|
||||
{title && (
|
||||
<div style={headerStyle}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
|
||||
</>
|
||||
)
|
||||
case "completion_result":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
|
||||
<Markdown markdown={message.text} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "shell_integration_warning":
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "rgba(255, 191, 0, 0.1)",
|
||||
padding: 8,
|
||||
borderRadius: 3,
|
||||
fontSize: 12,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: 4 }}>
|
||||
<i
|
||||
className="codicon codicon-warning"
|
||||
style={{
|
||||
marginRight: 8,
|
||||
fontSize: 18,
|
||||
color: "#FFA500",
|
||||
}}></i>
|
||||
<span style={{ fontWeight: 500, color: "#FFA500" }}>
|
||||
Shell Integration Unavailable
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Claude won't be able to view the command's output. Please update VSCode (
|
||||
<code>CMD/CTRL + Shift + P</code> → "Update") and make sure you're using a supported
|
||||
shell: zsh, bash, fish, or PowerShell (<code>CMD/CTRL + Shift + P</code> →
|
||||
"Terminal: Select Default Profile").{" "}
|
||||
<a
|
||||
href="https://github.com/saoudrizwan/claude-dev/wiki/Troubleshooting-%E2%80%90-Shell-Integration-Unavailable"
|
||||
style={{ color: "inherit", textDecoration: "underline" }}>
|
||||
Still having trouble?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
{title && (
|
||||
<div style={headerStyle}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ paddingTop: 10 }}>
|
||||
<Markdown markdown={message.text} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
case "ask":
|
||||
switch (message.ask) {
|
||||
case "mistake_limit_reached":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
|
||||
</>
|
||||
)
|
||||
case "command":
|
||||
const splitMessage = (text: string) => {
|
||||
const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING)
|
||||
if (outputIndex === -1) {
|
||||
return { command: text, output: "" }
|
||||
}
|
||||
return {
|
||||
command: text.slice(0, outputIndex).trim(),
|
||||
output: text
|
||||
.slice(outputIndex + COMMAND_OUTPUT_STRING.length)
|
||||
.trim()
|
||||
.split("")
|
||||
.map((char) => {
|
||||
switch (char) {
|
||||
case "\t":
|
||||
return "→ "
|
||||
case "\b":
|
||||
return "⌫"
|
||||
case "\f":
|
||||
return "⏏"
|
||||
case "\v":
|
||||
return "⇳"
|
||||
default:
|
||||
return char
|
||||
}
|
||||
})
|
||||
.join(""),
|
||||
}
|
||||
}
|
||||
|
||||
const { command, output } = splitMessage(message.text || "")
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
{/* <Terminal
|
||||
rawOutput={command + (output ? "\n" + output : "")}
|
||||
shouldAllowInput={!!isCommandExecuting && output.length > 0}
|
||||
/> */}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
border: "1px solid var(--vscode-editorGroup-border)",
|
||||
overflow: "hidden",
|
||||
backgroundColor: CODE_BLOCK_BG_COLOR,
|
||||
}}>
|
||||
<CodeBlock source={`${"```"}shell\n${command}\n${"```"}`} forceWrap={true} />
|
||||
{output.length > 0 && (
|
||||
<div style={{ width: "100%" }}>
|
||||
<div
|
||||
onClick={onToggleExpand}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
width: "100%",
|
||||
justifyContent: "flex-start",
|
||||
cursor: "pointer",
|
||||
padding: `2px 8px ${isExpanded ? 0 : 8}px 8px`,
|
||||
}}>
|
||||
<span
|
||||
className={`codicon codicon-chevron-${
|
||||
isExpanded ? "down" : "right"
|
||||
}`}></span>
|
||||
<span style={{ fontSize: "0.8em" }}>Command Output</span>
|
||||
</div>
|
||||
{isExpanded && <CodeBlock source={`${"```"}shell\n${output}\n${"```"}`} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "completion_result":
|
||||
if (message.text) {
|
||||
return (
|
||||
<div>
|
||||
<div style={headerStyle}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
|
||||
<Markdown markdown={message.text} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return null // Don't render anything when we get a completion_result ask without text
|
||||
}
|
||||
case "followup":
|
||||
return (
|
||||
<>
|
||||
{title && (
|
||||
<div style={headerStyle}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ paddingTop: 10 }}>
|
||||
<Markdown markdown={message.text} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ProgressIndicator = () => (
|
||||
<div
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
<div style={{ transform: "scale(0.55)", transformOrigin: "center" }}>
|
||||
<VSCodeProgressRing />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Markdown = memo(({ markdown }: { markdown?: string }) => {
|
||||
// react-markdown lets us customize elements, so here we're using their example of replacing code blocks with SyntaxHighlighter. However when there are no language matches (` or ``` without a language specifier) then we default to a normal code element for inline code. Code blocks without a language specifier shouldn't be a common occurrence as we prompt Claude to always use a language specifier.
|
||||
// when claude wraps text in thinking tags, he doesnt use line breaks so we need to insert those ourselves to render markdown correctly
|
||||
const parsed = markdown?.replace(/<thinking>([\s\S]*?)<\/thinking>/g, (match, content) => {
|
||||
return content
|
||||
// return `_<thinking>_\n\n${content}\n\n_</thinking>_`
|
||||
})
|
||||
return (
|
||||
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere", marginBottom: -10, marginTop: -10 }}>
|
||||
<ReactMarkdown
|
||||
children={parsed}
|
||||
components={{
|
||||
p(props) {
|
||||
const { style, ...rest } = props
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
...style,
|
||||
margin: 0,
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
},
|
||||
ol(props) {
|
||||
const { style, ...rest } = props
|
||||
return (
|
||||
<ol
|
||||
style={{
|
||||
...style,
|
||||
padding: "0 0 0 20px",
|
||||
margin: "10px 0",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
},
|
||||
ul(props) {
|
||||
const { style, ...rest } = props
|
||||
return (
|
||||
<ul
|
||||
style={{
|
||||
...style,
|
||||
padding: "0 0 0 20px",
|
||||
margin: "10px 0",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
},
|
||||
// pre always surrounds a code, and we custom handle code blocks below. Pre has some non-10 margin, while all other elements in markdown have a 10 top/bottom margin and the outer div has a -10 top/bottom margin to counteract this between chat rows. However we render markdown in a completion_result row so make sure to add padding as necessary when used within other rows.
|
||||
pre(props) {
|
||||
const { style, ...rest } = props
|
||||
return (
|
||||
<pre
|
||||
style={{
|
||||
...style,
|
||||
marginTop: 10,
|
||||
marginBlock: 10,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
},
|
||||
// https://github.com/remarkjs/react-markdown?tab=readme-ov-file#use-custom-components-syntax-highlight
|
||||
code(props) {
|
||||
const { children, className, node, ...rest } = props
|
||||
const match = /language-(\w+)/.exec(className || "")
|
||||
return match ? (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
border: "1px solid var(--vscode-editorGroup-border)",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<CodeBlock
|
||||
source={`${"```"}${match[1]}\n${String(children).replace(/\n$/, "")}\n${"```"}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<code
|
||||
{...rest}
|
||||
className={className}
|
||||
style={{
|
||||
whiteSpace: "pre-line",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
backgroundColor: "var(--vscode-textCodeBlock-background)",
|
||||
color: "var(--vscode-textPreformat-foreground)",
|
||||
fontFamily: "var(--vscode-editor-font-family)",
|
||||
fontSize: "var(--vscode-editor-font-size)",
|
||||
borderRadius: "3px",
|
||||
border: "1px solid var(--vscode-textSeparator-foreground)",
|
||||
padding: "0px 2px",
|
||||
}}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
600
webview-ui/src/components/chat/ChatTextArea.tsx
Normal file
600
webview-ui/src/components/chat/ChatTextArea.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
||||
import DynamicTextArea from "react-textarea-autosize"
|
||||
import { mentionRegex, mentionRegexGlobal } from "../../../src/shared/context-mentions"
|
||||
import { useExtensionState } from "../context/ExtensionStateContext"
|
||||
import {
|
||||
ContextMenuOptionType,
|
||||
getContextMenuOptions,
|
||||
insertMention,
|
||||
removeMention,
|
||||
shouldShowContextMenu,
|
||||
} from "../utils/context-mentions"
|
||||
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
|
||||
import ContextMenu from "./ContextMenu"
|
||||
import Thumbnails from "./Thumbnails"
|
||||
|
||||
interface ChatTextAreaProps {
|
||||
inputValue: string
|
||||
setInputValue: (value: string) => void
|
||||
textAreaDisabled: boolean
|
||||
placeholderText: string
|
||||
selectedImages: string[]
|
||||
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>
|
||||
onSend: () => void
|
||||
onSelectImages: () => void
|
||||
shouldDisableImages: boolean
|
||||
onHeightChange?: (height: number) => void
|
||||
}
|
||||
|
||||
const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
(
|
||||
{
|
||||
inputValue,
|
||||
setInputValue,
|
||||
textAreaDisabled,
|
||||
placeholderText,
|
||||
selectedImages,
|
||||
setSelectedImages,
|
||||
onSend,
|
||||
onSelectImages,
|
||||
shouldDisableImages,
|
||||
onHeightChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { filePaths } = useExtensionState()
|
||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
|
||||
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
|
||||
const [showContextMenu, setShowContextMenu] = useState(false)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const [isMouseDownOnMenu, setIsMouseDownOnMenu] = useState(false)
|
||||
const highlightLayerRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1)
|
||||
const [selectedType, setSelectedType] = useState<ContextMenuOptionType | null>(null)
|
||||
const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false)
|
||||
const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null)
|
||||
const contextMenuContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const queryItems = useMemo(() => {
|
||||
return [
|
||||
{ type: ContextMenuOptionType.Problems, value: "problems" },
|
||||
...filePaths
|
||||
.map((file) => "/" + file)
|
||||
.map((path) => ({
|
||||
type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
|
||||
value: path,
|
||||
})),
|
||||
]
|
||||
}, [filePaths])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
contextMenuContainerRef.current &&
|
||||
!contextMenuContainerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowContextMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showContextMenu) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}, [showContextMenu, setShowContextMenu])
|
||||
|
||||
const handleMentionSelect = useCallback(
|
||||
(type: ContextMenuOptionType, value?: string) => {
|
||||
if (type === ContextMenuOptionType.NoResults) {
|
||||
return
|
||||
}
|
||||
|
||||
if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
|
||||
if (!value) {
|
||||
setSelectedType(type)
|
||||
setSearchQuery("")
|
||||
setSelectedMenuIndex(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setShowContextMenu(false)
|
||||
setSelectedType(null)
|
||||
if (textAreaRef.current) {
|
||||
let insertValue = value || ""
|
||||
if (type === ContextMenuOptionType.URL) {
|
||||
insertValue = value || ""
|
||||
} else if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
|
||||
insertValue = value || ""
|
||||
} else if (type === ContextMenuOptionType.Problems) {
|
||||
insertValue = "problems"
|
||||
}
|
||||
|
||||
const { newValue, mentionIndex } = insertMention(
|
||||
textAreaRef.current.value,
|
||||
cursorPosition,
|
||||
insertValue
|
||||
)
|
||||
|
||||
setInputValue(newValue)
|
||||
const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1
|
||||
setCursorPosition(newCursorPosition)
|
||||
setIntendedCursorPosition(newCursorPosition)
|
||||
// textAreaRef.current.focus()
|
||||
|
||||
// scroll to cursor
|
||||
setTimeout(() => {
|
||||
if (textAreaRef.current) {
|
||||
textAreaRef.current.blur()
|
||||
textAreaRef.current.focus()
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
},
|
||||
[setInputValue, cursorPosition]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showContextMenu) {
|
||||
if (event.key === "Escape") {
|
||||
// event.preventDefault()
|
||||
setSelectedType(null)
|
||||
setSelectedMenuIndex(3) // File by default
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
setSelectedMenuIndex((prevIndex) => {
|
||||
const direction = event.key === "ArrowUp" ? -1 : 1
|
||||
const options = getContextMenuOptions(searchQuery, selectedType, queryItems)
|
||||
const optionsLength = options.length
|
||||
|
||||
if (optionsLength === 0) return prevIndex
|
||||
|
||||
// Find selectable options (non-URL types)
|
||||
const selectableOptions = options.filter(
|
||||
(option) =>
|
||||
option.type !== ContextMenuOptionType.URL &&
|
||||
option.type !== ContextMenuOptionType.NoResults
|
||||
)
|
||||
|
||||
if (selectableOptions.length === 0) return -1 // No selectable options
|
||||
|
||||
// Find the index of the next selectable option
|
||||
const currentSelectableIndex = selectableOptions.findIndex(
|
||||
(option) => option === options[prevIndex]
|
||||
)
|
||||
|
||||
const newSelectableIndex =
|
||||
(currentSelectableIndex + direction + selectableOptions.length) %
|
||||
selectableOptions.length
|
||||
|
||||
// Find the index of the selected option in the original options array
|
||||
return options.findIndex((option) => option === selectableOptions[newSelectableIndex])
|
||||
})
|
||||
return
|
||||
}
|
||||
if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) {
|
||||
event.preventDefault()
|
||||
const selectedOption = getContextMenuOptions(searchQuery, selectedType, queryItems)[
|
||||
selectedMenuIndex
|
||||
]
|
||||
if (
|
||||
selectedOption &&
|
||||
selectedOption.type !== ContextMenuOptionType.URL &&
|
||||
selectedOption.type !== ContextMenuOptionType.NoResults
|
||||
) {
|
||||
handleMentionSelect(selectedOption.type, selectedOption.value)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const isComposing = event.nativeEvent?.isComposing ?? false
|
||||
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
|
||||
event.preventDefault()
|
||||
onSend()
|
||||
}
|
||||
|
||||
if (event.key === "Backspace" && !isComposing) {
|
||||
const charBeforeCursor = inputValue[cursorPosition - 1]
|
||||
const charAfterCursor = inputValue[cursorPosition + 1]
|
||||
|
||||
const charBeforeIsWhitespace =
|
||||
charBeforeCursor === " " || charBeforeCursor === "\n" || charBeforeCursor === "\r\n"
|
||||
const charAfterIsWhitespace =
|
||||
charAfterCursor === " " || charAfterCursor === "\n" || charAfterCursor === "\r\n"
|
||||
// checks if char before cusor is whitespace after a mention
|
||||
if (
|
||||
charBeforeIsWhitespace &&
|
||||
inputValue.slice(0, cursorPosition - 1).match(new RegExp(mentionRegex.source + "$")) // "$" is added to ensure the match occurs at the end of the string
|
||||
) {
|
||||
const newCursorPosition = cursorPosition - 1
|
||||
// if mention is followed by another word, then instead of deleting the space separating them we just move the cursor to the end of the mention
|
||||
if (!charAfterIsWhitespace) {
|
||||
event.preventDefault()
|
||||
textAreaRef.current?.setSelectionRange(newCursorPosition, newCursorPosition)
|
||||
setCursorPosition(newCursorPosition)
|
||||
}
|
||||
setCursorPosition(newCursorPosition)
|
||||
setJustDeletedSpaceAfterMention(true)
|
||||
} else if (justDeletedSpaceAfterMention) {
|
||||
const { newText, newPosition } = removeMention(inputValue, cursorPosition)
|
||||
if (newText !== inputValue) {
|
||||
event.preventDefault()
|
||||
setInputValue(newText)
|
||||
setIntendedCursorPosition(newPosition) // Store the new cursor position in state
|
||||
}
|
||||
setJustDeletedSpaceAfterMention(false)
|
||||
setShowContextMenu(false)
|
||||
} else {
|
||||
setJustDeletedSpaceAfterMention(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
onSend,
|
||||
showContextMenu,
|
||||
searchQuery,
|
||||
selectedMenuIndex,
|
||||
handleMentionSelect,
|
||||
selectedType,
|
||||
inputValue,
|
||||
cursorPosition,
|
||||
setInputValue,
|
||||
justDeletedSpaceAfterMention,
|
||||
queryItems,
|
||||
]
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (intendedCursorPosition !== null && textAreaRef.current) {
|
||||
textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition)
|
||||
setIntendedCursorPosition(null) // Reset the state
|
||||
}
|
||||
}, [inputValue, intendedCursorPosition])
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
const newCursorPosition = e.target.selectionStart
|
||||
setInputValue(newValue)
|
||||
setCursorPosition(newCursorPosition)
|
||||
const showMenu = shouldShowContextMenu(newValue, newCursorPosition)
|
||||
|
||||
setShowContextMenu(showMenu)
|
||||
if (showMenu) {
|
||||
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
|
||||
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
|
||||
setSearchQuery(query)
|
||||
if (query.length > 0) {
|
||||
setSelectedMenuIndex(0)
|
||||
} else {
|
||||
setSelectedMenuIndex(3) // Set to "File" option by default
|
||||
}
|
||||
} else {
|
||||
setSearchQuery("")
|
||||
setSelectedMenuIndex(-1)
|
||||
}
|
||||
},
|
||||
[setInputValue]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showContextMenu) {
|
||||
setSelectedType(null)
|
||||
}
|
||||
}, [showContextMenu])
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// Only hide the context menu if the user didn't click on it
|
||||
if (!isMouseDownOnMenu) {
|
||||
setShowContextMenu(false)
|
||||
}
|
||||
setIsTextAreaFocused(false)
|
||||
}, [isMouseDownOnMenu])
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData.items
|
||||
|
||||
const pastedText = e.clipboardData.getData("text")
|
||||
// Check if the pasted content is a URL, add space after so user can easily delete if they don't want it
|
||||
const urlRegex = /^\S+:\/\/\S+$/
|
||||
if (urlRegex.test(pastedText.trim())) {
|
||||
e.preventDefault()
|
||||
const trimmedUrl = pastedText.trim()
|
||||
const newValue =
|
||||
inputValue.slice(0, cursorPosition) + trimmedUrl + " " + inputValue.slice(cursorPosition)
|
||||
setInputValue(newValue)
|
||||
const newCursorPosition = cursorPosition + trimmedUrl.length + 1
|
||||
setCursorPosition(newCursorPosition)
|
||||
setIntendedCursorPosition(newCursorPosition)
|
||||
setShowContextMenu(false)
|
||||
|
||||
// Scroll to new cursor position
|
||||
// https://stackoverflow.com/questions/29899364/how-do-you-scroll-to-the-position-of-the-cursor-in-a-textarea/40951875#40951875
|
||||
setTimeout(() => {
|
||||
if (textAreaRef.current) {
|
||||
textAreaRef.current.blur()
|
||||
textAreaRef.current.focus()
|
||||
}
|
||||
}, 0)
|
||||
// NOTE: callbacks dont utilize return function to cleanup, but it's fine since this timeout immediately executes and will be cleaned up by the browser (no chance component unmounts before it executes)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const acceptedTypes = ["png", "jpeg", "webp"] // supported by anthropic and openrouter (jpg is just a file extension but the image will be recognized as jpeg)
|
||||
const imageItems = Array.from(items).filter((item) => {
|
||||
const [type, subtype] = item.type.split("/")
|
||||
return type === "image" && acceptedTypes.includes(subtype)
|
||||
})
|
||||
if (!shouldDisableImages && imageItems.length > 0) {
|
||||
e.preventDefault()
|
||||
const imagePromises = imageItems.map((item) => {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
const blob = item.getAsFile()
|
||||
if (!blob) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
if (reader.error) {
|
||||
console.error("Error reading file:", reader.error)
|
||||
resolve(null)
|
||||
} else {
|
||||
const result = reader.result
|
||||
resolve(typeof result === "string" ? result : null)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
})
|
||||
const imageDataArray = await Promise.all(imagePromises)
|
||||
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
|
||||
//.map((dataUrl) => dataUrl.split(",")[1]) // strip the mime type prefix, sharp doesn't need it
|
||||
if (dataUrls.length > 0) {
|
||||
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
|
||||
} else {
|
||||
console.warn("No valid images were processed")
|
||||
}
|
||||
}
|
||||
},
|
||||
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue]
|
||||
)
|
||||
|
||||
const handleThumbnailsHeightChange = useCallback((height: number) => {
|
||||
setThumbnailsHeight(height)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedImages.length === 0) {
|
||||
setThumbnailsHeight(0)
|
||||
}
|
||||
}, [selectedImages])
|
||||
|
||||
const handleMenuMouseDown = useCallback(() => {
|
||||
setIsMouseDownOnMenu(true)
|
||||
}, [])
|
||||
|
||||
const updateHighlights = useCallback(() => {
|
||||
if (!textAreaRef.current || !highlightLayerRef.current) return
|
||||
|
||||
const text = textAreaRef.current.value
|
||||
|
||||
highlightLayerRef.current.innerHTML = text
|
||||
.replace(/\n$/, "\n\n")
|
||||
.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" }[c] || c))
|
||||
.replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')
|
||||
|
||||
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
|
||||
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateHighlights()
|
||||
}, [inputValue, updateHighlights])
|
||||
|
||||
const updateCursorPosition = useCallback(() => {
|
||||
if (textAreaRef.current) {
|
||||
setCursorPosition(textAreaRef.current.selectionStart)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
|
||||
updateCursorPosition()
|
||||
}
|
||||
},
|
||||
[updateCursorPosition]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 15px",
|
||||
opacity: textAreaDisabled ? 0.5 : 1,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
}}>
|
||||
{showContextMenu && (
|
||||
<div ref={contextMenuContainerRef}>
|
||||
<ContextMenu
|
||||
onSelect={handleMentionSelect}
|
||||
searchQuery={searchQuery}
|
||||
onMouseDown={handleMenuMouseDown}
|
||||
selectedIndex={selectedMenuIndex}
|
||||
setSelectedIndex={setSelectedMenuIndex}
|
||||
selectedType={selectedType}
|
||||
queryItems={queryItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isTextAreaFocused && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: "10px 15px",
|
||||
border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: 2,
|
||||
pointerEvents: "none",
|
||||
zIndex: 5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={highlightLayerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
left: 15,
|
||||
right: 15,
|
||||
bottom: 10,
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordWrap: "break-word",
|
||||
color: "transparent",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--vscode-input-background)",
|
||||
fontFamily: "var(--vscode-font-family)",
|
||||
fontSize: "var(--vscode-editor-font-size)",
|
||||
lineHeight: "var(--vscode-editor-line-height)",
|
||||
borderRadius: 2,
|
||||
borderLeft: 0,
|
||||
borderRight: 0,
|
||||
borderTop: 0,
|
||||
borderColor: "transparent",
|
||||
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
|
||||
padding: "9px 49px 3px 9px",
|
||||
}}
|
||||
/>
|
||||
<DynamicTextArea
|
||||
ref={(el) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(el)
|
||||
} else if (ref) {
|
||||
ref.current = el
|
||||
}
|
||||
textAreaRef.current = el
|
||||
}}
|
||||
value={inputValue}
|
||||
disabled={textAreaDisabled}
|
||||
onChange={(e) => {
|
||||
handleInputChange(e)
|
||||
updateHighlights()
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onFocus={() => setIsTextAreaFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
onPaste={handlePaste}
|
||||
onSelect={updateCursorPosition}
|
||||
onMouseUp={updateCursorPosition}
|
||||
onHeightChange={(height) => {
|
||||
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
|
||||
setTextAreaBaseHeight(height)
|
||||
}
|
||||
onHeightChange?.(height)
|
||||
}}
|
||||
placeholder={placeholderText}
|
||||
maxRows={10}
|
||||
autoFocus={true}
|
||||
style={{
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--vscode-input-foreground)",
|
||||
//border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: 2,
|
||||
fontFamily: "var(--vscode-font-family)",
|
||||
fontSize: "var(--vscode-editor-font-size)",
|
||||
lineHeight: "var(--vscode-editor-line-height)",
|
||||
resize: "none",
|
||||
overflowX: "hidden",
|
||||
overflowY: "scroll",
|
||||
scrollbarWidth: "none",
|
||||
// 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)
|
||||
// borderTop: "9px solid transparent",
|
||||
borderLeft: 0,
|
||||
borderRight: 0,
|
||||
borderTop: 0,
|
||||
borderBottom: `${thumbnailsHeight + 6}px solid transparent`,
|
||||
borderColor: "transparent",
|
||||
// borderRight: "54px solid transparent",
|
||||
// borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead
|
||||
// Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused
|
||||
// boxShadow: "0px 0px 0px 1px var(--vscode-input-border)",
|
||||
padding: "9px 49px 3px 9px",
|
||||
cursor: textAreaDisabled ? "not-allowed" : undefined,
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
}}
|
||||
onScroll={() => updateHighlights()}
|
||||
/>
|
||||
{selectedImages.length > 0 && (
|
||||
<Thumbnails
|
||||
images={selectedImages}
|
||||
setImages={setSelectedImages}
|
||||
onHeightChange={handleThumbnailsHeightChange}
|
||||
style={{
|
||||
position: "absolute",
|
||||
paddingTop: 4,
|
||||
bottom: 14,
|
||||
left: 22,
|
||||
right: 67, // (54 + 9) + 4 extra padding
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 23,
|
||||
display: "flex",
|
||||
alignItems: "flex-center",
|
||||
height: textAreaBaseHeight || 31,
|
||||
bottom: 9.5, // should be 10 but doesnt look good on mac
|
||||
zIndex: 2,
|
||||
}}>
|
||||
<div style={{ display: "flex", flexDirection: "row", alignItems: "center" }}>
|
||||
<div
|
||||
className={`input-icon-button ${
|
||||
shouldDisableImages ? "disabled" : ""
|
||||
} codicon codicon-device-camera`}
|
||||
onClick={() => {
|
||||
if (!shouldDisableImages) {
|
||||
onSelectImages()
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
marginRight: 5.5,
|
||||
fontSize: 16.5,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
|
||||
onClick={() => {
|
||||
if (!textAreaDisabled) {
|
||||
onSend()
|
||||
}
|
||||
}}
|
||||
style={{ fontSize: 15 }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default ChatTextArea
|
||||
589
webview-ui/src/components/chat/ChatView.tsx
Normal file
589
webview-ui/src/components/chat/ChatView.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useEvent, useMount } from "react-use"
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
|
||||
import { ClaudeAsk, ClaudeSayTool, ExtensionMessage } from "../../../src/shared/ExtensionMessage"
|
||||
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 Announcement from "./Announcement"
|
||||
import { normalizeApiConfiguration } from "./ApiOptions"
|
||||
import ChatRow from "./ChatRow"
|
||||
import ChatTextArea from "./ChatTextArea"
|
||||
import HistoryPreview from "./HistoryPreview"
|
||||
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 = 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)
|
||||
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 [isAtBottom, setIsAtBottom] = useState(false)
|
||||
const [didScrollFromApiReqTs, setDidScrollFromApiReqTs] = useState<number | undefined>(undefined)
|
||||
|
||||
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":
|
||||
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(false)
|
||||
setClaudeAsk("followup")
|
||||
setEnableButtons(false)
|
||||
// setPrimaryButtonText(undefined)
|
||||
// setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "tool":
|
||||
setTextAreaDisabled(false)
|
||||
setClaudeAsk("tool")
|
||||
setEnableButtons(true)
|
||||
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(false)
|
||||
setClaudeAsk("command")
|
||||
setEnableButtons(true)
|
||||
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(false)
|
||||
setClaudeAsk("completion_result")
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Start New Task")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "resume_task":
|
||||
setTextAreaDisabled(false)
|
||||
setClaudeAsk("resume_task")
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Resume Task")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "resume_completed_task":
|
||||
setTextAreaDisabled(false)
|
||||
setClaudeAsk("resume_completed_task")
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Start New Task")
|
||||
setSecondaryButtonText(undefined)
|
||||
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 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(() => {
|
||||
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])
|
||||
|
||||
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])
|
||||
|
||||
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 isLastCollapsed = !expandedRows[visibleMessages.at(-1)?.ts ?? 0]
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[ts]: !prev[ts],
|
||||
}))
|
||||
|
||||
if (isCollapsing && isAtBottom) {
|
||||
const timer = setTimeout(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: visibleMessages.length - 1,
|
||||
align: "end",
|
||||
})
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (isLast || isSecondToLast) {
|
||||
if (isCollapsing) {
|
||||
if (isSecondToLast && !isLastCollapsed) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: visibleMessages.length - 1,
|
||||
align: "end",
|
||||
})
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
const timer = setTimeout(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: visibleMessages.length - (isLast ? 1 : 2),
|
||||
align: "start",
|
||||
})
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
},
|
||||
[isAtBottom, visibleMessages, expandedRows]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// dont scroll if we're just updating the api req started informational body
|
||||
const lastMessage = visibleMessages.at(-1)
|
||||
const isLastApiReqStarted = lastMessage?.say === "api_req_started"
|
||||
if (didScrollFromApiReqTs && isLastApiReqStarted && lastMessage?.ts === didScrollFromApiReqTs) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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.
|
||||
const timer = setTimeout(() => {
|
||||
// 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
|
||||
// NOTE: scroll to bottom may not work if you use margin, see virtuoso's troubleshooting
|
||||
virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" })
|
||||
setDidScrollFromApiReqTs(isLastApiReqStarted ? lastMessage?.ts : undefined) // need to do this in timer since this effect can get called a few times simultaneously
|
||||
}, 50)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [visibleMessages, didScrollFromApiReqTs])
|
||||
|
||||
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}
|
||||
/>
|
||||
),
|
||||
[expandedRows, modifiedMessages, visibleMessages.length, toggleRowExpansion]
|
||||
)
|
||||
|
||||
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}
|
||||
className="scrollable"
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflowY: "scroll", // always show scrollbar
|
||||
}}
|
||||
// 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={setIsAtBottom}
|
||||
atBottomThreshold={100}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
opacity: primaryButtonText || secondaryButtonText ? (enableButtons ? 1 : 0.5) : 0,
|
||||
display: "flex",
|
||||
padding: "10px 15px 0px 15px",
|
||||
}}>
|
||||
{primaryButtonText && (
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
disabled={!enableButtons}
|
||||
style={{
|
||||
flex: secondaryButtonText ? 1 : 2,
|
||||
marginRight: secondaryButtonText ? "6px" : "0",
|
||||
}}
|
||||
onClick={handlePrimaryButtonClick}>
|
||||
{primaryButtonText}
|
||||
</VSCodeButton>
|
||||
)}
|
||||
{secondaryButtonText && (
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
disabled={!enableButtons}
|
||||
style={{ flex: 1, marginLeft: "6px" }}
|
||||
onClick={handleSecondaryButtonClick}>
|
||||
{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 (isAtBottom) {
|
||||
//virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "auto" })
|
||||
virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatView
|
||||
180
webview-ui/src/components/chat/ContextMenu.tsx
Normal file
180
webview-ui/src/components/chat/ContextMenu.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useEffect, useMemo, useRef } from "react"
|
||||
import { ContextMenuOptionType, ContextMenuQueryItem, getContextMenuOptions } from "../utils/context-mentions"
|
||||
import { removeLeadingNonAlphanumeric } from "./CodeAccordian"
|
||||
|
||||
interface ContextMenuProps {
|
||||
onSelect: (type: ContextMenuOptionType, value?: string) => void
|
||||
searchQuery: string
|
||||
onMouseDown: () => void
|
||||
selectedIndex: number
|
||||
setSelectedIndex: (index: number) => void
|
||||
selectedType: ContextMenuOptionType | null
|
||||
queryItems: ContextMenuQueryItem[]
|
||||
}
|
||||
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
onSelect,
|
||||
searchQuery,
|
||||
onMouseDown,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
selectedType,
|
||||
queryItems,
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() => getContextMenuOptions(searchQuery, selectedType, queryItems),
|
||||
[searchQuery, selectedType, queryItems]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
const selectedElement = menuRef.current.children[selectedIndex] as HTMLElement
|
||||
if (selectedElement) {
|
||||
const menuRect = menuRef.current.getBoundingClientRect()
|
||||
const selectedRect = selectedElement.getBoundingClientRect()
|
||||
|
||||
if (selectedRect.bottom > menuRect.bottom) {
|
||||
menuRef.current.scrollTop += selectedRect.bottom - menuRect.bottom
|
||||
} else if (selectedRect.top < menuRect.top) {
|
||||
menuRef.current.scrollTop -= menuRect.top - selectedRect.top
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
const renderOptionContent = (option: ContextMenuQueryItem) => {
|
||||
switch (option.type) {
|
||||
case ContextMenuOptionType.Problems:
|
||||
return <span>Problems</span>
|
||||
case ContextMenuOptionType.URL:
|
||||
return <span>Paste URL to fetch contents</span>
|
||||
case ContextMenuOptionType.NoResults:
|
||||
return <span>No results found</span>
|
||||
case ContextMenuOptionType.File:
|
||||
case ContextMenuOptionType.Folder:
|
||||
if (option.value) {
|
||||
return (
|
||||
<>
|
||||
<span>/</span>
|
||||
{option.value?.startsWith("/.") && <span>.</span>}
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
direction: "rtl",
|
||||
textAlign: "left",
|
||||
}}>
|
||||
{removeLeadingNonAlphanumeric(option.value || "") + "\u200E"}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return <span>Add {option.type === ContextMenuOptionType.File ? "File" : "Folder"}</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getIconForOption = (option: ContextMenuQueryItem): string => {
|
||||
switch (option.type) {
|
||||
case ContextMenuOptionType.File:
|
||||
return "file"
|
||||
case ContextMenuOptionType.Folder:
|
||||
return "folder"
|
||||
case ContextMenuOptionType.Problems:
|
||||
return "warning"
|
||||
case ContextMenuOptionType.URL:
|
||||
return "link"
|
||||
case ContextMenuOptionType.NoResults:
|
||||
return "info"
|
||||
default:
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
const isOptionSelectable = (option: ContextMenuQueryItem): boolean => {
|
||||
return option.type !== ContextMenuOptionType.NoResults && option.type !== ContextMenuOptionType.URL
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% - 10px)",
|
||||
left: 15,
|
||||
right: 15,
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
onMouseDown={onMouseDown}>
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
backgroundColor: "var(--vscode-dropdown-background)",
|
||||
border: "1px solid var(--vscode-editorGroup-border)",
|
||||
borderRadius: "3px",
|
||||
boxShadow: "0 4px 10px rgba(0, 0, 0, 0.25)",
|
||||
zIndex: 1000,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
}}>
|
||||
{/* Can't use virtuoso since it requires fixed height and menu height is dynamic based on # of items */}
|
||||
{filteredOptions.map((option, index) => (
|
||||
<div
|
||||
key={`${option.type}-${option.value || index}`}
|
||||
onClick={() => isOptionSelectable(option) && onSelect(option.type, option.value)}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
cursor: isOptionSelectable(option) ? "pointer" : "default",
|
||||
color: "var(--vscode-dropdown-foreground)",
|
||||
borderBottom: "1px solid var(--vscode-editorGroup-border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
backgroundColor:
|
||||
index === selectedIndex && isOptionSelectable(option)
|
||||
? "var(--vscode-list-activeSelectionBackground)"
|
||||
: "",
|
||||
}}
|
||||
onMouseEnter={() => isOptionSelectable(option) && setSelectedIndex(index)}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<i
|
||||
className={`codicon codicon-${getIconForOption(option)}`}
|
||||
style={{ marginRight: "8px", flexShrink: 0, fontSize: "14px" }}
|
||||
/>
|
||||
{renderOptionContent(option)}
|
||||
</div>
|
||||
{(option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder) &&
|
||||
!option.value && (
|
||||
<i
|
||||
className="codicon codicon-chevron-right"
|
||||
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
|
||||
/>
|
||||
)}
|
||||
{(option.type === ContextMenuOptionType.Problems ||
|
||||
((option.type === ContextMenuOptionType.File ||
|
||||
option.type === ContextMenuOptionType.Folder) &&
|
||||
option.value)) && (
|
||||
<i
|
||||
className="codicon codicon-add"
|
||||
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContextMenu
|
||||
378
webview-ui/src/components/chat/TaskHeader.tsx
Normal file
378
webview-ui/src/components/chat/TaskHeader.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useWindowSize } from "react-use"
|
||||
import { ClaudeMessage } from "../../../../src/shared/ExtensionMessage"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import { mentionRegexGlobal } from "../../../../src/shared/context-mentions"
|
||||
|
||||
interface TaskHeaderProps {
|
||||
task: ClaudeMessage
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
doesModelSupportPromptCache: boolean
|
||||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
totalCost: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
task,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
doesModelSupportPromptCache,
|
||||
cacheWrites,
|
||||
cacheReads,
|
||||
totalCost,
|
||||
onClose,
|
||||
}) => {
|
||||
const { apiConfiguration } = useExtensionState()
|
||||
const [isTaskExpanded, setIsTaskExpanded] = useState(true)
|
||||
const [isTextExpanded, setIsTextExpanded] = useState(false)
|
||||
const [showSeeMore, setShowSeeMore] = useState(false)
|
||||
const textContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/*
|
||||
When dealing with event listeners in React components that depend on state variables, we face a challenge. We want our listener to always use the most up-to-date version of a callback function that relies on current state, but we don't want to constantly add and remove event listeners as that function updates. This scenario often arises with resize listeners or other window events. Simply adding the listener in a useEffect with an empty dependency array risks using stale state, while including the callback in the dependencies can lead to unnecessary re-registrations of the listener. There are react hook libraries that provide a elegant solution to this problem by utilizing the useRef hook to maintain a reference to the latest callback function without triggering re-renders or effect re-runs. This approach ensures that our event listener always has access to the most current state while minimizing performance overhead and potential memory leaks from multiple listener registrations.
|
||||
Sources
|
||||
- https://usehooks-ts.com/react-hook/use-event-listener
|
||||
- https://streamich.github.io/react-use/?path=/story/sensors-useevent--docs
|
||||
- https://github.com/streamich/react-use/blob/master/src/useEvent.ts
|
||||
- https://stackoverflow.com/questions/55565444/how-to-register-event-with-useeffect-hooks
|
||||
|
||||
Before:
|
||||
|
||||
const updateMaxHeight = useCallback(() => {
|
||||
if (isExpanded && textContainerRef.current) {
|
||||
const maxHeight = window.innerHeight * (3 / 5)
|
||||
textContainerRef.current.style.maxHeight = `${maxHeight}px`
|
||||
}
|
||||
}, [isExpanded])
|
||||
|
||||
useEffect(() => {
|
||||
updateMaxHeight()
|
||||
}, [isExpanded, updateMaxHeight])
|
||||
|
||||
useEffect(() => {
|
||||
window.removeEventListener("resize", updateMaxHeight)
|
||||
window.addEventListener("resize", updateMaxHeight)
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateMaxHeight)
|
||||
}
|
||||
}, [updateMaxHeight])
|
||||
|
||||
After:
|
||||
*/
|
||||
|
||||
const { height: windowHeight, width: windowWidth } = useWindowSize()
|
||||
|
||||
useEffect(() => {
|
||||
if (isTextExpanded && textContainerRef.current) {
|
||||
const maxHeight = windowHeight * (1 / 2)
|
||||
textContainerRef.current.style.maxHeight = `${maxHeight}px`
|
||||
}
|
||||
}, [isTextExpanded, windowHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current && textContainerRef.current) {
|
||||
let textContainerHeight = textContainerRef.current.clientHeight
|
||||
if (!textContainerHeight) {
|
||||
textContainerHeight = textContainerRef.current.getBoundingClientRect().height
|
||||
}
|
||||
const isOverflowing = textRef.current.scrollHeight > textContainerHeight
|
||||
// necessary to show see more button again if user resizes window to expand and then back to collapse
|
||||
if (!isOverflowing) {
|
||||
setIsTextExpanded(false)
|
||||
}
|
||||
setShowSeeMore(isOverflowing)
|
||||
}
|
||||
}, [task.text, windowWidth])
|
||||
|
||||
const isCostAvailable = useMemo(() => {
|
||||
return (
|
||||
apiConfiguration?.apiProvider !== "openai" &&
|
||||
apiConfiguration?.apiProvider !== "ollama" &&
|
||||
apiConfiguration?.apiProvider !== "gemini"
|
||||
)
|
||||
}, [apiConfiguration?.apiProvider])
|
||||
|
||||
const shouldShowPromptCacheInfo = doesModelSupportPromptCache && apiConfiguration?.apiProvider !== "openrouter"
|
||||
|
||||
return (
|
||||
<div style={{ padding: "10px 13px 10px 13px" }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--vscode-badge-background)",
|
||||
color: "var(--vscode-badge-foreground)",
|
||||
borderRadius: "3px",
|
||||
padding: "9px 10px 9px 14px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
marginLeft: -2,
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
flexGrow: 1,
|
||||
minWidth: 0, // This allows the div to shrink below its content size
|
||||
}}
|
||||
onClick={() => setIsTaskExpanded(!isTaskExpanded)}>
|
||||
<div style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||
<span className={`codicon codicon-chevron-${isTaskExpanded ? "down" : "right"}`}></span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
flexGrow: 1,
|
||||
minWidth: 0, // This allows the div to shrink below its content size
|
||||
}}>
|
||||
<span style={{ fontWeight: "bold" }}>Task{!isTaskExpanded && ":"}</span>
|
||||
{!isTaskExpanded && (
|
||||
<span style={{ marginLeft: 4 }}>{highlightMentions(task.text, false)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isTaskExpanded && isCostAvailable && (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 10,
|
||||
backgroundColor: "color-mix(in srgb, var(--vscode-badge-foreground) 70%, transparent)",
|
||||
color: "var(--vscode-badge-background)",
|
||||
padding: "2px 4px",
|
||||
borderRadius: "500px",
|
||||
fontSize: "11px",
|
||||
fontWeight: 500,
|
||||
display: "inline-block",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
${totalCost?.toFixed(4)}
|
||||
</div>
|
||||
)}
|
||||
<VSCodeButton appearance="icon" onClick={onClose} style={{ marginLeft: 6, flexShrink: 0 }}>
|
||||
<span className="codicon codicon-close"></span>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
{isTaskExpanded && (
|
||||
<>
|
||||
<div
|
||||
ref={textContainerRef}
|
||||
style={{
|
||||
marginTop: -2,
|
||||
fontSize: "var(--vscode-font-size)",
|
||||
overflowY: isTextExpanded ? "auto" : "hidden",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
position: "relative",
|
||||
}}>
|
||||
<div
|
||||
ref={textRef}
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: isTextExpanded ? "unset" : 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}>
|
||||
{highlightMentions(task.text, false)}
|
||||
</div>
|
||||
{!isTextExpanded && showSeeMore && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: "1.2em",
|
||||
background:
|
||||
"linear-gradient(to right, transparent, var(--vscode-badge-background))",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--vscode-textLink-foreground)",
|
||||
paddingRight: 0,
|
||||
paddingLeft: 3,
|
||||
backgroundColor: "var(--vscode-badge-background)",
|
||||
}}
|
||||
onClick={() => setIsTextExpanded(!isTextExpanded)}>
|
||||
See more
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isTextExpanded && showSeeMore && (
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--vscode-textLink-foreground)",
|
||||
marginLeft: "auto",
|
||||
textAlign: "right",
|
||||
paddingRight: 2,
|
||||
}}
|
||||
onClick={() => setIsTextExpanded(!isTextExpanded)}>
|
||||
See less
|
||||
</div>
|
||||
)}
|
||||
{task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
|
||||
<span style={{ fontWeight: "bold" }}>Tokens:</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
|
||||
<i
|
||||
className="codicon codicon-arrow-up"
|
||||
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-2px" }}
|
||||
/>
|
||||
{tokensIn?.toLocaleString()}
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
|
||||
<i
|
||||
className="codicon codicon-arrow-down"
|
||||
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-2px" }}
|
||||
/>
|
||||
{tokensOut?.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{!isCostAvailable && <ExportButton />}
|
||||
</div>
|
||||
|
||||
{(shouldShowPromptCacheInfo || cacheReads !== undefined || cacheWrites !== undefined) && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
|
||||
<span style={{ fontWeight: "bold" }}>Cache:</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
|
||||
<i
|
||||
className="codicon codicon-database"
|
||||
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-1px" }}
|
||||
/>
|
||||
+{(cacheWrites || 0)?.toLocaleString()}
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
|
||||
<i
|
||||
className="codicon codicon-arrow-right"
|
||||
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: 0 }}
|
||||
/>
|
||||
{(cacheReads || 0)?.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isCostAvailable && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<span style={{ fontWeight: "bold" }}>API Cost:</span>
|
||||
<span>${totalCost?.toFixed(4)}</span>
|
||||
</div>
|
||||
<ExportButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* {apiProvider === "kodu" && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "color-mix(in srgb, var(--vscode-badge-background) 50%, transparent)",
|
||||
color: "var(--vscode-badge-foreground)",
|
||||
borderRadius: "0 0 3px 3px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "4px 12px 6px 12px",
|
||||
fontSize: "0.9em",
|
||||
marginLeft: "10px",
|
||||
marginRight: "10px",
|
||||
}}>
|
||||
<div style={{ fontWeight: "500" }}>Credits Remaining:</div>
|
||||
<div>
|
||||
{formatPrice(koduCredits || 0)}
|
||||
{(koduCredits || 0) < 1 && (
|
||||
<>
|
||||
{" "}
|
||||
<VSCodeLink style={{ fontSize: "0.9em" }} href={getKoduAddCreditsUrl(vscodeUriScheme)}>
|
||||
(get more?)
|
||||
</VSCodeLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const highlightMentions = (text?: string, withShadow = true) => {
|
||||
if (!text) return text
|
||||
const parts = text.split(mentionRegexGlobal)
|
||||
return parts.map((part, index) => {
|
||||
if (index % 2 === 0) {
|
||||
// This is regular text
|
||||
return part
|
||||
} else {
|
||||
// This is a mention
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => vscode.postMessage({ type: "openMention", text: part })}>
|
||||
@{part}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const ExportButton = () => (
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}
|
||||
style={
|
||||
{
|
||||
// marginBottom: "-2px",
|
||||
// marginRight: "-2.5px",
|
||||
}
|
||||
}>
|
||||
<div style={{ fontSize: "10.5px", fontWeight: "bold", opacity: 0.6 }}>EXPORT</div>
|
||||
</VSCodeButton>
|
||||
)
|
||||
|
||||
export default memo(TaskHeader)
|
||||
Reference in New Issue
Block a user