Performance optimizations with memoization and useCallbacks

This commit is contained in:
Saoud Rizwan
2024-09-06 14:35:02 -04:00
parent 76868f21d1
commit 428d3c39b5
12 changed files with 578 additions and 562 deletions

View File

@@ -1,5 +1,6 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { ApiConfiguration } from "../../../src/shared/api" import { ApiConfiguration } from "../../../src/shared/api"
import { memo } from "react"
// import VSCodeButtonLink from "./VSCodeButtonLink" // import VSCodeButtonLink from "./VSCodeButtonLink"
// import { getOpenRouterAuthUrl } from "./ApiOptions" // import { getOpenRouterAuthUrl } from "./ApiOptions"
// import { vscode } from "../utils/vscode" // import { vscode } from "../utils/vscode"
@@ -89,4 +90,4 @@ const Announcement = ({ version, hideAnnouncement, apiConfiguration, vscodeUriSc
) )
} }
export default Announcement export default memo(Announcement)

View File

@@ -6,7 +6,7 @@ import {
VSCodeRadioGroup, VSCodeRadioGroup,
VSCodeTextField, VSCodeTextField,
} from "@vscode/webview-ui-toolkit/react" } from "@vscode/webview-ui-toolkit/react"
import React, { useCallback, useEffect, useMemo, useState } from "react" import { memo, useCallback, useEffect, useMemo, useState } from "react"
import { import {
ApiConfiguration, ApiConfiguration,
ModelInfo, ModelInfo,
@@ -31,7 +31,7 @@ interface ApiOptionsProps {
apiErrorMessage?: string apiErrorMessage?: string
} }
const ApiOptions: React.FC<ApiOptionsProps> = ({ showModelOptions, apiErrorMessage }) => { const ApiOptions = ({ showModelOptions, apiErrorMessage }: ApiOptionsProps) => {
const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState()
const [ollamaModels, setOllamaModels] = useState<string[]>([]) const [ollamaModels, setOllamaModels] = useState<string[]>([])
@@ -550,4 +550,4 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
} }
} }
export default ApiOptions export default memo(ApiOptions)

View File

@@ -1,13 +1,13 @@
import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react" import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
import React from "react" import React, { memo, useMemo } from "react"
import Markdown from "react-markdown" import ReactMarkdown from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "../../../src/shared/ExtensionMessage" import { ClaudeMessage, ClaudeSayTool } from "../../../src/shared/ExtensionMessage"
import { COMMAND_OUTPUT_STRING } from "../../../src/shared/combineCommandSequences" import { COMMAND_OUTPUT_STRING } from "../../../src/shared/combineCommandSequences"
import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme" import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme"
import CodeBlock from "./CodeBlock" import CodeBlock from "./CodeBlock"
import Thumbnails from "./Thumbnails"
import Terminal from "./Terminal" import Terminal from "./Terminal"
import Thumbnails from "./Thumbnails"
interface ChatRowProps { interface ChatRowProps {
message: ClaudeMessage message: ClaudeMessage
@@ -19,7 +19,21 @@ interface ChatRowProps {
handleSendStdin: (text: string) => void handleSendStdin: (text: string) => void
} }
const ChatRow: React.FC<ChatRowProps> = ({ 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>
)
})
export default ChatRow
const ChatRowContent = ({
message, message,
syntaxHighlighterStyle, syntaxHighlighterStyle,
isExpanded, isExpanded,
@@ -27,35 +41,26 @@ const ChatRow: React.FC<ChatRowProps> = ({
lastModifiedMessage, lastModifiedMessage,
isLast, isLast,
handleSendStdin, handleSendStdin,
}) => { }: ChatRowProps) => {
const cost = message.text != null && message.say === "api_req_started" ? JSON.parse(message.text).cost : undefined 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 = const apiRequestFailedMessage =
isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried
? lastModifiedMessage?.text ? lastModifiedMessage?.text
: undefined : undefined
const isCommandExecuting = const isCommandExecuting =
isLast && lastModifiedMessage?.ask === "command" && lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING) isLast && lastModifiedMessage?.ask === "command" && lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING)
const type = message.type === "ask" ? message.ask : message.say
const getIconAndTitle = (type: ClaudeAsk | ClaudeSay | undefined): [JSX.Element | null, JSX.Element | null] => {
const normalColor = "var(--vscode-foreground)" const normalColor = "var(--vscode-foreground)"
const errorColor = "var(--vscode-errorForeground)" const errorColor = "var(--vscode-errorForeground)"
const successColor = "var(--vscode-charts-green)" const successColor = "var(--vscode-charts-green)"
const ProgressIndicator = ( const [icon, title] = useMemo(() => {
<div
style={{
width: "16px",
height: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<div style={{ transform: "scale(0.55)", transformOrigin: "center" }}>
<VSCodeProgressRing />
</div>
</div>
)
switch (type) { switch (type) {
case "error": case "error":
return [ return [
@@ -74,7 +79,7 @@ const ChatRow: React.FC<ChatRowProps> = ({
case "command": case "command":
return [ return [
isCommandExecuting ? ( isCommandExecuting ? (
ProgressIndicator <ProgressIndicator />
) : ( ) : (
<span <span
className="codicon codicon-terminal" className="codicon codicon-terminal"
@@ -102,7 +107,7 @@ const ChatRow: React.FC<ChatRowProps> = ({
className="codicon codicon-error" className="codicon codicon-error"
style={{ color: errorColor, marginBottom: "-1.5px" }}></span> style={{ color: errorColor, marginBottom: "-1.5px" }}></span>
) : ( ) : (
ProgressIndicator <ProgressIndicator />
), ),
cost != null ? ( cost != null ? (
<span style={{ color: normalColor, fontWeight: "bold" }}>API Request Complete</span> <span style={{ color: normalColor, fontWeight: "bold" }}>API Request Complete</span>
@@ -122,120 +127,7 @@ const ChatRow: React.FC<ChatRowProps> = ({
default: default:
return [null, null] return [null, null]
} }
} }, [type, cost, apiRequestFailedMessage, isCommandExecuting])
const renderMarkdown = (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 `_<thinking>_\n\n${content}\n\n_</thinking>_`
})
return (
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere" }}>
<Markdown
children={parsed}
components={{
p(props) {
const { style, ...rest } = props
return (
<p
style={{
...style,
margin: 0,
marginTop: 0,
marginBottom: 0,
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}
/>
)
},
// 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 ? (
<SyntaxHighlighter
{...(rest as any)} // will be passed down to pre
PreTag="div"
children={String(children).replace(/\n$/, "")}
language={match[1]}
style={{
...syntaxHighlighterStyle,
'code[class*="language-"]': {
background: "var(--vscode-editor-background)",
},
'pre[class*="language-"]': {
background: "var(--vscode-editor-background)",
},
}}
customStyle={{
overflowX: "auto",
overflowY: "hidden",
maxWidth: "100%",
margin: 0,
padding: "10px",
// important to note that min-width: max-content is not required here how it is in CodeBlock.tsx
borderRadius: 3,
border: "1px solid var(--vscode-sideBar-border)",
fontSize: "var(--vscode-editor-font-size)",
lineHeight: "var(--vscode-editor-line-height)",
fontFamily: "var(--vscode-editor-font-family)",
}}
/>
) : (
<code
{...rest}
className={className}
style={{
whiteSpace: "pre-line",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
{children}
</code>
)
},
}}
/>
</div>
)
}
const renderContent = () => {
const [icon, title] = getIconAndTitle(message.type === "ask" ? message.ask : message.say)
const headerStyle: React.CSSProperties = { const headerStyle: React.CSSProperties = {
display: "flex", display: "flex",
@@ -251,237 +143,14 @@ const ChatRow: React.FC<ChatRowProps> = ({
overflowWrap: "anywhere", overflowWrap: "anywhere",
} }
switch (message.type) { const tool = useMemo(() => {
case "say": if (message.ask === "tool" || message.say === "tool") {
switch (message.say) { return JSON.parse(message.text || "{}") as ClaudeSayTool
case "api_req_started":
return (
<>
<div
style={{
...headerStyle,
marginBottom: cost == null && apiRequestFailedMessage ? 10 : 0,
justifyContent: "space-between",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
{icon}
{title}
{cost != null && cost > 0 && (
<VSCodeBadge>${Number(cost)?.toFixed(4)}</VSCodeBadge>
)}
</div>
<VSCodeButton
appearance="icon"
aria-label="Toggle Details"
onClick={onToggleExpand}>
<span
className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
</VSCodeButton>
</div>
{cost == null && apiRequestFailedMessage && (
<>
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>
{apiRequestFailedMessage}
</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>
)} */}
</>
)}
</>
)
case "api_req_finished":
return null // we should never see this message type
case "text":
return <div>{renderMarkdown(message.text)}</div>
case "user_feedback":
return (
<div
style={{
backgroundColor: "var(--vscode-badge-background)",
color: "var(--vscode-badge-foreground)",
borderRadius: "3px",
padding: "8px",
whiteSpace: "pre-line",
wordWrap: "break-word",
}}>
<span style={{ display: "block" }}>{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={{
backgroundColor: "var(--vscode-editor-inactiveSelectionBackground)",
borderRadius: "3px",
padding: "8px",
whiteSpace: "pre-line",
wordWrap: "break-word",
}}>
<span
style={{
display: "block",
fontStyle: "italic",
marginBottom: "8px",
opacity: 0.8,
}}>
The user made the following changes:
</span>
<CodeBlock
diff={tool.diff!}
path={tool.path!}
syntaxHighlighterStyle={syntaxHighlighterStyle}
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)" }}>
{renderMarkdown(message.text)}
</div>
</>
)
case "tool":
return renderTool(message, headerStyle)
default:
return (
<>
{title && (
<div style={headerStyle}>
{icon}
{title}
</div>
)}
<div>{renderMarkdown(message.text)}</div>
</>
)
}
case "ask":
switch (message.ask) {
case "tool":
return renderTool(message, headerStyle)
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() + " ",
}
} }
return null
}, [message.ask, message.say, message.text])
const { command, output } = splitMessage(message.text || "") if (tool) {
return (
<>
<div style={headerStyle}>
{icon}
{title}
</div>
<Terminal
rawOutput={command + (output ? "\n" + output : "")}
handleSendStdin={handleSendStdin}
shouldAllowInput={!!isCommandExecuting && output.length > 0}
/>
</>
)
case "completion_result":
if (message.text) {
return (
<div>
<div style={headerStyle}>
{icon}
{title}
</div>
<div style={{ color: "var(--vscode-charts-green)" }}>
{renderMarkdown(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>{renderMarkdown(message.text)}</div>
</>
)
}
}
}
const renderTool = (message: ClaudeMessage, headerStyle: React.CSSProperties) => {
const tool = JSON.parse(message.text || "{}") as ClaudeSayTool
const toolIcon = (name: string) => ( const toolIcon = (name: string) => (
<span <span
className={`codicon codicon-${name}`} className={`codicon codicon-${name}`}
@@ -633,15 +302,68 @@ const ChatRow: React.FC<ChatRowProps> = ({
} }
} }
// NOTE: we cannot return null as virtuoso does not support it, so we must use a separate visibleMessages array to filter out messages that should not be rendered switch (message.type) {
case "say":
switch (message.say) {
case "api_req_started":
return ( return (
<>
<div <div
style={{ style={{
padding: "10px 6px 10px 15px", ...headerStyle,
marginBottom: cost == null && apiRequestFailedMessage ? 10 : 0,
justifyContent: "space-between",
}}> }}>
{renderContent()} <div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
{isExpanded && message.say === "api_req_started" && ( {icon}
{title}
{cost != null && cost > 0 && <VSCodeBadge>${Number(cost)?.toFixed(4)}</VSCodeBadge>}
</div>
<VSCodeButton appearance="icon" aria-label="Toggle Details" onClick={onToggleExpand}>
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
</VSCodeButton>
</div>
{cost == null && apiRequestFailedMessage && (
<>
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>
{apiRequestFailedMessage}
</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" }}> <div style={{ marginTop: "10px" }}>
<CodeBlock <CodeBlock
code={JSON.stringify(JSON.parse(message.text || "{}").request, null, 2)} code={JSON.stringify(JSON.parse(message.text || "{}").request, null, 2)}
@@ -652,8 +374,299 @@ const ChatRow: React.FC<ChatRowProps> = ({
/> />
</div> </div>
)} )}
</>
)
case "api_req_finished":
return null // we should never see this message type
case "text":
return (
<div>
<Markdown syntaxHighlighterStyle={syntaxHighlighterStyle} markdown={message.text} />
</div>
)
case "user_feedback":
return (
<div
style={{
backgroundColor: "var(--vscode-badge-background)",
color: "var(--vscode-badge-foreground)",
borderRadius: "3px",
padding: "8px",
whiteSpace: "pre-line",
wordWrap: "break-word",
}}>
<span style={{ display: "block" }}>{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={{
backgroundColor: "var(--vscode-editor-inactiveSelectionBackground)",
borderRadius: "3px",
padding: "8px",
whiteSpace: "pre-line",
wordWrap: "break-word",
}}>
<span
style={{
display: "block",
fontStyle: "italic",
marginBottom: "8px",
opacity: 0.8,
}}>
The user made the following changes:
</span>
<CodeBlock
diff={tool.diff!}
path={tool.path!}
syntaxHighlighterStyle={syntaxHighlighterStyle}
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)" }}>
<Markdown syntaxHighlighterStyle={syntaxHighlighterStyle} markdown={message.text} />
</div>
</>
)
default:
return (
<>
{title && (
<div style={headerStyle}>
{icon}
{title}
</div>
)}
<div>
<Markdown syntaxHighlighterStyle={syntaxHighlighterStyle} 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() + " ",
}
}
const { command, output } = splitMessage(message.text || "")
return (
<>
<div style={headerStyle}>
{icon}
{title}
</div>
<Terminal
rawOutput={command + (output ? "\n" + output : "")}
handleSendStdin={handleSendStdin}
shouldAllowInput={!!isCommandExecuting && output.length > 0}
/>
</>
)
case "completion_result":
if (message.text) {
return (
<div>
<div style={headerStyle}>
{icon}
{title}
</div>
<div style={{ color: "var(--vscode-charts-green)" }}>
<Markdown syntaxHighlighterStyle={syntaxHighlighterStyle} 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>
<Markdown syntaxHighlighterStyle={syntaxHighlighterStyle} 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(
({ syntaxHighlighterStyle, markdown }: { syntaxHighlighterStyle: SyntaxHighlighterStyle; 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 `_<thinking>_\n\n${content}\n\n_</thinking>_`
})
return (
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere" }}>
<ReactMarkdown
children={parsed}
components={{
p(props) {
const { style, ...rest } = props
return (
<p
style={{
...style,
margin: 0,
marginTop: 0,
marginBottom: 0,
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}
/>
)
},
// 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 ? (
<SyntaxHighlighter
{...(rest as any)} // will be passed down to pre
PreTag="div"
children={String(children).replace(/\n$/, "")}
language={match[1]}
style={{
...syntaxHighlighterStyle,
'code[class*="language-"]': {
background: "var(--vscode-editor-background)",
},
'pre[class*="language-"]': {
background: "var(--vscode-editor-background)",
},
}}
customStyle={{
overflowX: "auto",
overflowY: "hidden",
maxWidth: "100%",
margin: 0,
padding: "10px",
// important to note that min-width: max-content is not required here how it is in CodeBlock.tsx
borderRadius: 3,
border: "1px solid var(--vscode-sideBar-border)",
fontSize: "var(--vscode-editor-font-size)",
lineHeight: "var(--vscode-editor-line-height)",
fontFamily: "var(--vscode-editor-font-family)",
}}
/>
) : (
<code
{...rest}
className={className}
style={{
whiteSpace: "pre-line",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
{children}
</code>
)
},
}}
/>
</div> </div>
) )
} }
)
export default ChatRow

View File

@@ -215,7 +215,7 @@ const ChatView = ({
} }
}, [messages.length]) }, [messages.length])
const handleSendMessage = () => { const handleSendMessage = useCallback(() => {
const text = inputValue.trim() const text = inputValue.trim()
if (text || selectedImages.length > 0) { if (text || selectedImages.length > 0) {
if (messages.length === 0) { if (messages.length === 0) {
@@ -248,9 +248,10 @@ const ChatView = ({
// setPrimaryButtonText(undefined) // setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined) // setSecondaryButtonText(undefined)
} }
} }, [inputValue, selectedImages, messages.length, claudeAsk])
const handleSendStdin = (text: string) => { const handleSendStdin = useCallback(
(text: string) => {
if (claudeAsk === "command_output") { if (claudeAsk === "command_output") {
vscode.postMessage({ vscode.postMessage({
type: "askResponse", type: "askResponse",
@@ -262,12 +263,18 @@ const ChatView = ({
// setTextAreaDisabled(true) // setTextAreaDisabled(true)
// setEnableButtons(false) // setEnableButtons(false)
} }
} },
[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. 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 = () => { const handlePrimaryButtonClick = useCallback(() => {
switch (claudeAsk) { switch (claudeAsk) {
case "api_req_failed": case "api_req_failed":
case "command": case "command":
@@ -288,9 +295,9 @@ const ChatView = ({
setEnableButtons(false) setEnableButtons(false)
// setPrimaryButtonText(undefined) // setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined) // setSecondaryButtonText(undefined)
} }, [claudeAsk, startNewTask])
const handleSecondaryButtonClick = () => { const handleSecondaryButtonClick = useCallback(() => {
switch (claudeAsk) { switch (claudeAsk) {
case "api_req_failed": case "api_req_failed":
case "mistake_limit_reached": case "mistake_limit_reached":
@@ -307,29 +314,32 @@ const ChatView = ({
setEnableButtons(false) setEnableButtons(false)
// setPrimaryButtonText(undefined) // setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined) // setSecondaryButtonText(undefined)
} }, [claudeAsk, startNewTask])
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
const isComposing = event.nativeEvent?.isComposing ?? false const isComposing = event.nativeEvent?.isComposing ?? false
if (event.key === "Enter" && !event.shiftKey && !isComposing) { if (event.key === "Enter" && !event.shiftKey && !isComposing) {
event.preventDefault() event.preventDefault()
handleSendMessage() handleSendMessage()
} }
} },
[handleSendMessage]
)
const handleTaskCloseButtonClick = () => { const handleTaskCloseButtonClick = useCallback(() => {
startNewTask() startNewTask()
} }, [startNewTask])
const startNewTask = () => { const selectImages = useCallback(() => {
vscode.postMessage({ type: "clearTask" })
}
const selectImages = () => {
vscode.postMessage({ type: "selectImages" }) vscode.postMessage({ type: "selectImages" })
} }, [])
const handlePaste = async (e: React.ClipboardEvent) => { const shouldDisableImages =
!selectedModelSupportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
const items = e.clipboardData.items const items = e.clipboardData.items
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 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 imageItems = Array.from(items).filter((item) => {
@@ -367,7 +377,9 @@ const ChatView = ({
console.warn("No valid images were processed") console.warn("No valid images were processed")
} }
} }
} },
[shouldDisableImages, setSelectedImages]
)
useEffect(() => { useEffect(() => {
if (selectedImages.length === 0) { if (selectedImages.length === 0) {
@@ -469,8 +481,21 @@ const ChatView = ({
return text return text
}, [task]) }, [task])
const shouldDisableImages = const itemContent = useCallback(
!selectedModelSupportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE (index: number, message: any) => (
<ChatRow
key={message.ts}
message={message}
syntaxHighlighterStyle={syntaxHighlighterStyle}
isExpanded={expandedRows[message.ts] || false}
onToggleExpand={() => toggleRowExpansion(message.ts)}
lastModifiedMessage={modifiedMessages.at(-1)}
isLast={index === visibleMessages.length - 1}
handleSendStdin={handleSendStdin}
/>
),
[expandedRows, syntaxHighlighterStyle, modifiedMessages, visibleMessages.length, handleSendStdin]
)
return ( return (
<div <div
@@ -540,18 +565,7 @@ const ChatView = ({
// }} // }}
increaseViewportBy={{ top: 0, bottom: Number.MAX_SAFE_INTEGER }} // hack to make sure the last message is always rendered to get truly perfect scroll to bottom animation when new messages are added (Number.MAX_SAFE_INTEGER is safe for arithmetic operations, which is all virtuoso uses this value for in src/sizeRangeSystem.ts) increaseViewportBy={{ top: 0, bottom: Number.MAX_SAFE_INTEGER }} // hack to make sure the last message is always rendered to get truly perfect scroll to bottom animation when new messages are added (Number.MAX_SAFE_INTEGER is safe for arithmetic operations, which is all virtuoso uses this value for in src/sizeRangeSystem.ts)
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 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={(index, message) => ( itemContent={itemContent}
<ChatRow
key={message.ts}
message={message}
syntaxHighlighterStyle={syntaxHighlighterStyle}
isExpanded={expandedRows[message.ts] || false}
onToggleExpand={() => toggleRowExpansion(message.ts)}
lastModifiedMessage={modifiedMessages.at(-1)}
isLast={index === visibleMessages.length - 1}
handleSendStdin={handleSendStdin}
/>
)}
/> />
<div <div
style={{ style={{

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react" import { memo, useMemo } from "react"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { getLanguageFromPath } from "../utils/getLanguageFromPath" import { getLanguageFromPath } from "../utils/getLanguageFromPath"
import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme" import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme"
@@ -67,6 +67,14 @@ interface CodeBlockProps {
onToggleExpand: () => void onToggleExpand: () => void
} }
/*
We need to remove leading non-alphanumeric characters from the path in order for our leading ellipses trick to work.
^: Anchors the match to the start of the string.
[^a-zA-Z0-9]+: Matches one or more characters that are not alphanumeric.
The replace method removes these matched characters, effectively trimming the string up to the first alphanumeric character.
*/
const removeLeadingNonAlphanumeric = (path: string): string => path.replace(/^[^a-zA-Z0-9]+/, "")
const CodeBlock = ({ const CodeBlock = ({
code, code,
diff, diff,
@@ -76,15 +84,6 @@ const CodeBlock = ({
isExpanded, isExpanded,
onToggleExpand, onToggleExpand,
}: CodeBlockProps) => { }: CodeBlockProps) => {
/*
We need to remove leading non-alphanumeric characters from the path in order for our leading ellipses trick to work.
^: Anchors the match to the start of the string.
[^a-zA-Z0-9]+: Matches one or more characters that are not alphanumeric.
The replace method removes these matched characters, effectively trimming the string up to the first alphanumeric character.
*/
const removeLeadingNonAlphanumeric = (path: string): string => path.replace(/^[^a-zA-Z0-9]+/, "")
const inferredLanguage = useMemo( const inferredLanguage = useMemo(
() => code && (language ?? (path ? getLanguageFromPath(path) : undefined)), () => code && (language ?? (path ? getLanguageFromPath(path) : undefined)),
[path, language, code] [path, language, code]
@@ -168,5 +167,5 @@ const CodeBlock = ({
</div> </div>
) )
} }
// memo does shallow comparison of props, so if you need it to re-render when a nested object changes, you need to pass a custom comparison function
export default CodeBlock export default memo(CodeBlock)

View File

@@ -1,6 +1,7 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "../context/ExtensionStateContext" import { useExtensionState } from "../context/ExtensionStateContext"
import { vscode } from "../utils/vscode" import { vscode } from "../utils/vscode"
import { memo } from "react"
type HistoryPreviewProps = { type HistoryPreviewProps = {
showHistoryView: () => void showHistoryView: () => void
@@ -148,4 +149,4 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
) )
} }
export default HistoryPreview export default memo(HistoryPreview)

View File

@@ -2,7 +2,7 @@ import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "../context/ExtensionStateContext" import { useExtensionState } from "../context/ExtensionStateContext"
import { vscode } from "../utils/vscode" import { vscode } from "../utils/vscode"
import { Virtuoso } from "react-virtuoso" import { Virtuoso } from "react-virtuoso"
import { useMemo, useState } from "react" import { memo, useMemo, useState } from "react"
type HistoryViewProps = { type HistoryViewProps = {
onDone: () => void onDone: () => void
@@ -20,10 +20,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
vscode.postMessage({ type: "deleteTaskWithId", text: id }) vscode.postMessage({ type: "deleteTaskWithId", text: id })
} }
const handleExportMd = (id: string) => {
vscode.postMessage({ type: "exportTaskWithId", text: id })
}
const formatDate = (timestamp: number) => { const formatDate = (timestamp: number) => {
const date = new Date(timestamp) const date = new Date(timestamp)
return date return date
@@ -63,18 +59,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
) )
} }
const ExportButton = ({ itemId }: { itemId: string }) => (
<VSCodeButton
className="export-button"
appearance="icon"
onClick={(e) => {
e.stopPropagation()
handleExportMd(itemId)
}}>
<div style={{ fontSize: "11px", fontWeight: 500, opacity: 1 }}>EXPORT</div>
</VSCodeButton>
)
return ( return (
<> <>
<style> <style>
@@ -369,4 +353,16 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
) )
} }
export default HistoryView const ExportButton = ({ itemId }: { itemId: string }) => (
<VSCodeButton
className="export-button"
appearance="icon"
onClick={(e) => {
e.stopPropagation()
vscode.postMessage({ type: "exportTaskWithId", text: itemId })
}}>
<div style={{ fontSize: "11px", fontWeight: 500, opacity: 1 }}>EXPORT</div>
</VSCodeButton>
)
export default memo(HistoryView)

View File

@@ -1,5 +1,5 @@
import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react" import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react"
import { useEffect, useState } from "react" import { memo, useEffect, useState } from "react"
import { useExtensionState } from "../context/ExtensionStateContext" import { useExtensionState } from "../context/ExtensionStateContext"
import { validateApiConfiguration } from "../utils/validate" import { validateApiConfiguration } from "../utils/validate"
import { vscode } from "../utils/vscode" import { vscode } from "../utils/vscode"
@@ -162,4 +162,4 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
) )
} }
export default SettingsView export default memo(SettingsView)

View File

@@ -1,5 +1,5 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import React, { useEffect, useRef, useState } from "react" import React, { memo, useEffect, useRef, useState } from "react"
import { useWindowSize } from "react-use" import { useWindowSize } from "react-use"
import { ClaudeMessage } from "../../../src/shared/ExtensionMessage" import { ClaudeMessage } from "../../../src/shared/ExtensionMessage"
import { useExtensionState } from "../context/ExtensionStateContext" import { useExtensionState } from "../context/ExtensionStateContext"
@@ -89,24 +89,6 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
} }
}, [task.text, windowWidth]) }, [task.text, windowWidth])
const toggleExpand = () => setIsExpanded(!isExpanded)
const handleDownload = () => {
vscode.postMessage({ type: "exportCurrentTask" })
}
const ExportButton = () => (
<VSCodeButton
appearance="icon"
onClick={handleDownload}
style={{
marginBottom: "-2px",
marginRight: "-2.5px",
}}>
<div style={{ fontSize: "10.5px", fontWeight: "bold", opacity: 0.6 }}>EXPORT</div>
</VSCodeButton>
)
return ( return (
<div style={{ padding: "10px 13px 10px 13px" }}> <div style={{ padding: "10px 13px 10px 13px" }}>
<div <div
@@ -182,7 +164,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
paddingLeft: 3, paddingLeft: 3,
backgroundColor: "var(--vscode-badge-background)", backgroundColor: "var(--vscode-badge-background)",
}} }}
onClick={toggleExpand}> onClick={() => setIsExpanded(!isExpanded)}>
See more See more
</div> </div>
</div> </div>
@@ -197,7 +179,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
textAlign: "right", textAlign: "right",
paddingRight: 0, paddingRight: 0,
}} }}
onClick={toggleExpand}> onClick={() => setIsExpanded(!isExpanded)}>
See less See less
</div> </div>
)} )}
@@ -298,4 +280,16 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
) )
} }
export default TaskHeader 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)

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from "react" import React, { useState, useEffect, useRef, useMemo, memo } from "react"
import DynamicTextArea from "react-textarea-autosize" import DynamicTextArea from "react-textarea-autosize"
import stripAnsi from "strip-ansi" import stripAnsi from "strip-ansi"
@@ -14,7 +14,7 @@ Inspired by https://phuoc.ng/collection/mirror-a-text-area/create-your-own-custo
Note: Even though vscode exposes var(--vscode-terminalCursor-foreground) it does not render in front of a color that isn't var(--vscode-terminal-background), and it turns out a lot of themes don't even define some/any of these terminal color variables. Very odd behavior, so try changing themes/color variables if you don't see the caret. Note: Even though vscode exposes var(--vscode-terminalCursor-foreground) it does not render in front of a color that isn't var(--vscode-terminal-background), and it turns out a lot of themes don't even define some/any of these terminal color variables. Very odd behavior, so try changing themes/color variables if you don't see the caret.
*/ */
const Terminal: React.FC<TerminalProps> = ({ rawOutput, handleSendStdin, shouldAllowInput }) => { const Terminal = ({ rawOutput, handleSendStdin, shouldAllowInput }: TerminalProps) => {
const [userInput, setUserInput] = useState("") const [userInput, setUserInput] = useState("")
const [isFocused, setIsFocused] = useState(false) // Initially not focused const [isFocused, setIsFocused] = useState(false) // Initially not focused
const textAreaRef = useRef<HTMLTextAreaElement>(null) const textAreaRef = useRef<HTMLTextAreaElement>(null)
@@ -348,4 +348,4 @@ const Terminal: React.FC<TerminalProps> = ({ rawOutput, handleSendStdin, shouldA
) )
} }
export default Terminal export default memo(Terminal)

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useLayoutEffect } from "react" import React, { useState, useRef, useLayoutEffect, memo } from "react"
import { useWindowSize } from "react-use" import { useWindowSize } from "react-use"
interface ThumbnailsProps { interface ThumbnailsProps {
@@ -8,7 +8,7 @@ interface ThumbnailsProps {
onHeightChange?: (height: number) => void onHeightChange?: (height: number) => void
} }
const Thumbnails: React.FC<ThumbnailsProps> = ({ images, style, setImages, onHeightChange }) => { const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProps) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null) const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const { width } = useWindowSize() const { width } = useWindowSize()
@@ -88,4 +88,4 @@ const Thumbnails: React.FC<ThumbnailsProps> = ({ images, style, setImages, onHei
) )
} }
export default Thumbnails export default memo(Thumbnails)

View File

@@ -5,9 +5,7 @@ import { validateApiConfiguration } from "../utils/validate"
import { vscode } from "../utils/vscode" import { vscode } from "../utils/vscode"
import ApiOptions from "./ApiOptions" import ApiOptions from "./ApiOptions"
interface WelcomeViewProps {} const WelcomeView = () => {
const WelcomeView: React.FC<WelcomeViewProps> = () => {
const { apiConfiguration } = useExtensionState() const { apiConfiguration } = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined) const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)