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 { ApiConfiguration } from "../../../src/shared/api"
import { memo } from "react"
// import VSCodeButtonLink from "./VSCodeButtonLink"
// import { getOpenRouterAuthUrl } from "./ApiOptions"
// 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,
VSCodeTextField,
} from "@vscode/webview-ui-toolkit/react"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { memo, useCallback, useEffect, useMemo, useState } from "react"
import {
ApiConfiguration,
ModelInfo,
@@ -31,7 +31,7 @@ interface ApiOptionsProps {
apiErrorMessage?: string
}
const ApiOptions: React.FC<ApiOptionsProps> = ({ showModelOptions, apiErrorMessage }) => {
const ApiOptions = ({ showModelOptions, apiErrorMessage }: ApiOptionsProps) => {
const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState()
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 React from "react"
import Markdown from "react-markdown"
import React, { memo, useMemo } from "react"
import ReactMarkdown from "react-markdown"
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 { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme"
import CodeBlock from "./CodeBlock"
import Thumbnails from "./Thumbnails"
import Terminal from "./Terminal"
import Thumbnails from "./Thumbnails"
interface ChatRowProps {
message: ClaudeMessage
@@ -19,7 +19,21 @@ interface ChatRowProps {
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,
syntaxHighlighterStyle,
isExpanded,
@@ -27,35 +41,26 @@ const ChatRow: React.FC<ChatRowProps> = ({
lastModifiedMessage,
isLast,
handleSendStdin,
}) => {
const cost = message.text != null && message.say === "api_req_started" ? JSON.parse(message.text).cost : undefined
}: 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 getIconAndTitle = (type: ClaudeAsk | ClaudeSay | undefined): [JSX.Element | null, JSX.Element | null] => {
const normalColor = "var(--vscode-foreground)"
const errorColor = "var(--vscode-errorForeground)"
const successColor = "var(--vscode-charts-green)"
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 [icon, title] = useMemo(() => {
switch (type) {
case "error":
return [
@@ -74,7 +79,7 @@ const ChatRow: React.FC<ChatRowProps> = ({
case "command":
return [
isCommandExecuting ? (
ProgressIndicator
<ProgressIndicator />
) : (
<span
className="codicon codicon-terminal"
@@ -102,7 +107,7 @@ const ChatRow: React.FC<ChatRowProps> = ({
className="codicon codicon-error"
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>
) : (
ProgressIndicator
<ProgressIndicator />
),
cost != null ? (
<span style={{ color: normalColor, fontWeight: "bold" }}>API Request Complete</span>
@@ -122,120 +127,7 @@ const ChatRow: React.FC<ChatRowProps> = ({
default:
return [null, null]
}
}
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)
}, [type, cost, apiRequestFailedMessage, isCommandExecuting])
const headerStyle: React.CSSProperties = {
display: "flex",
@@ -251,237 +143,14 @@ const ChatRow: React.FC<ChatRowProps> = ({
overflowWrap: "anywhere",
}
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",
}}>
<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() + " ",
}
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])
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)" }}>
{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
if (tool) {
const toolIcon = (name: string) => (
<span
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 (
<>
<div
style={{
padding: "10px 6px 10px 15px",
...headerStyle,
marginBottom: cost == null && apiRequestFailedMessage ? 10 : 0,
justifyContent: "space-between",
}}>
{renderContent()}
{isExpanded && message.say === "api_req_started" && (
<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>
)} */}
</>
)}
{isExpanded && (
<div style={{ marginTop: "10px" }}>
<CodeBlock
code={JSON.stringify(JSON.parse(message.text || "{}").request, null, 2)}
@@ -652,8 +374,299 @@ const ChatRow: React.FC<ChatRowProps> = ({
/>
</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>
)
}
export default ChatRow
)

View File

@@ -215,7 +215,7 @@ const ChatView = ({
}
}, [messages.length])
const handleSendMessage = () => {
const handleSendMessage = useCallback(() => {
const text = inputValue.trim()
if (text || selectedImages.length > 0) {
if (messages.length === 0) {
@@ -248,9 +248,10 @@ const ChatView = ({
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
}
}
}, [inputValue, selectedImages, messages.length, claudeAsk])
const handleSendStdin = (text: string) => {
const handleSendStdin = useCallback(
(text: string) => {
if (claudeAsk === "command_output") {
vscode.postMessage({
type: "askResponse",
@@ -262,12 +263,18 @@ const ChatView = ({
// setTextAreaDisabled(true)
// 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.
*/
const handlePrimaryButtonClick = () => {
const handlePrimaryButtonClick = useCallback(() => {
switch (claudeAsk) {
case "api_req_failed":
case "command":
@@ -288,9 +295,9 @@ const ChatView = ({
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
}
}, [claudeAsk, startNewTask])
const handleSecondaryButtonClick = () => {
const handleSecondaryButtonClick = useCallback(() => {
switch (claudeAsk) {
case "api_req_failed":
case "mistake_limit_reached":
@@ -307,29 +314,32 @@ const ChatView = ({
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
}
}, [claudeAsk, startNewTask])
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
const isComposing = event.nativeEvent?.isComposing ?? false
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
event.preventDefault()
handleSendMessage()
}
}
},
[handleSendMessage]
)
const handleTaskCloseButtonClick = () => {
const handleTaskCloseButtonClick = useCallback(() => {
startNewTask()
}
}, [startNewTask])
const startNewTask = () => {
vscode.postMessage({ type: "clearTask" })
}
const selectImages = () => {
const selectImages = useCallback(() => {
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 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) => {
@@ -367,7 +377,9 @@ const ChatView = ({
console.warn("No valid images were processed")
}
}
}
},
[shouldDisableImages, setSelectedImages]
)
useEffect(() => {
if (selectedImages.length === 0) {
@@ -469,8 +481,21 @@ const ChatView = ({
return text
}, [task])
const shouldDisableImages =
!selectedModelSupportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
const itemContent = useCallback(
(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 (
<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)
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) => (
<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}
/>
)}
itemContent={itemContent}
/>
<div
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 { getLanguageFromPath } from "../utils/getLanguageFromPath"
import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme"
@@ -67,6 +67,14 @@ interface CodeBlockProps {
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 = ({
code,
diff,
@@ -76,15 +84,6 @@ const CodeBlock = ({
isExpanded,
onToggleExpand,
}: 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(
() => code && (language ?? (path ? getLanguageFromPath(path) : undefined)),
[path, language, code]
@@ -168,5 +167,5 @@ const CodeBlock = ({
</div>
)
}
export default CodeBlock
// 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 memo(CodeBlock)

View File

@@ -1,6 +1,7 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "../context/ExtensionStateContext"
import { vscode } from "../utils/vscode"
import { memo } from "react"
type HistoryPreviewProps = {
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 { vscode } from "../utils/vscode"
import { Virtuoso } from "react-virtuoso"
import { useMemo, useState } from "react"
import { memo, useMemo, useState } from "react"
type HistoryViewProps = {
onDone: () => void
@@ -20,10 +20,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
vscode.postMessage({ type: "deleteTaskWithId", text: id })
}
const handleExportMd = (id: string) => {
vscode.postMessage({ type: "exportTaskWithId", text: id })
}
const formatDate = (timestamp: number) => {
const date = new Date(timestamp)
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 (
<>
<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 { useEffect, useState } from "react"
import { memo, useEffect, useState } from "react"
import { useExtensionState } from "../context/ExtensionStateContext"
import { validateApiConfiguration } from "../utils/validate"
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 React, { useEffect, useRef, useState } from "react"
import React, { memo, useEffect, useRef, useState } from "react"
import { useWindowSize } from "react-use"
import { ClaudeMessage } from "../../../src/shared/ExtensionMessage"
import { useExtensionState } from "../context/ExtensionStateContext"
@@ -89,24 +89,6 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
}
}, [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 (
<div style={{ padding: "10px 13px 10px 13px" }}>
<div
@@ -182,7 +164,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
paddingLeft: 3,
backgroundColor: "var(--vscode-badge-background)",
}}
onClick={toggleExpand}>
onClick={() => setIsExpanded(!isExpanded)}>
See more
</div>
</div>
@@ -197,7 +179,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
textAlign: "right",
paddingRight: 0,
}}
onClick={toggleExpand}>
onClick={() => setIsExpanded(!isExpanded)}>
See less
</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 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.
*/
const Terminal: React.FC<TerminalProps> = ({ rawOutput, handleSendStdin, shouldAllowInput }) => {
const Terminal = ({ rawOutput, handleSendStdin, shouldAllowInput }: TerminalProps) => {
const [userInput, setUserInput] = useState("")
const [isFocused, setIsFocused] = useState(false) // Initially not focused
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"
interface ThumbnailsProps {
@@ -8,7 +8,7 @@ interface ThumbnailsProps {
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 containerRef = useRef<HTMLDivElement>(null)
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 ApiOptions from "./ApiOptions"
interface WelcomeViewProps {}
const WelcomeView: React.FC<WelcomeViewProps> = () => {
const WelcomeView = () => {
const { apiConfiguration } = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)