Add memory optimizations, retry failed requests, markdown support

- Move isExpanded state up into ChatView to fix issue where virtualized list would reset ChatRow state
- Add ability to retry failed requests
- Add markdown rendering
This commit is contained in:
Saoud Rizwan
2024-07-25 08:54:29 -04:00
parent 8058d261ba
commit 44a4140486
9 changed files with 1536 additions and 134 deletions

View File

@@ -50,6 +50,7 @@ RULES
- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.
- NEVER end completion_attempt with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user. - NEVER end completion_attempt with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.
- NEVER start your responses with affirmations like "Certaintly", "Okay", "Sure", "Great", etc. You should NOT be conversational in your responses, but rather direct and to the point. - NEVER start your responses with affirmations like "Certaintly", "Okay", "Sure", "Great", etc. You should NOT be conversational in your responses, but rather direct and to the point.
- Feel free to use markdown as much as you'd like in your responses. When using code blocks, always include a language specifier.
==== ====
@@ -222,7 +223,7 @@ export class ClaudeDev {
return result return result
} }
async say(type: ClaudeSay, text: string): Promise<undefined> { async say(type: ClaudeSay, text?: string): Promise<undefined> {
if (this.abort) { if (this.abort) {
throw new Error("ClaudeDev instance aborted") throw new Error("ClaudeDev instance aborted")
} }
@@ -502,6 +503,38 @@ export class ClaudeDev {
return `The user is not pleased with the results. Use the feedback they provided to successfully complete the task, and then attempt completion again.\nUser's feedback:\n\"${text}\"` return `The user is not pleased with the results. Use the feedback they provided to successfully complete the task, and then attempt completion again.\nUser's feedback:\n\"${text}\"`
} }
async attemptApiRequest(): Promise<Anthropic.Messages.Message> {
try {
const response = await this.client.messages.create(
{
model: "claude-3-5-sonnet-20240620", // https://docs.anthropic.com/en/docs/about-claude/models
// beta max tokens
max_tokens: 8192,
system: SYSTEM_PROMPT,
messages: (await this.providerRef.deref()?.getApiConversationHistory()) || [],
tools: tools,
tool_choice: { type: "auto" },
},
{
// https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers
headers: { "anthropic-beta": "max-tokens-3-5-sonnet-2024-07-15" },
}
)
return response
} catch (error) {
const { response } = await this.ask(
"api_req_failed",
error.message ?? JSON.stringify(serializeError(error), null, 2)
)
if (response !== "yesButtonTapped") {
// this will never happen since if noButtonTapped, we will clear current task, aborting this instance
throw new Error("API request failed")
}
await this.say("api_req_retried")
return this.attemptApiRequest()
}
}
async recursivelyMakeClaudeRequests( async recursivelyMakeClaudeRequests(
userContent: Array< userContent: Array<
| Anthropic.TextBlockParam | Anthropic.TextBlockParam
@@ -537,37 +570,22 @@ export class ClaudeDev {
} }
} }
try { // what the user sees in the webview
// what the user sees in the webview await this.say(
await this.say( "api_req_started",
"api_req_started", JSON.stringify({
JSON.stringify({ request: {
request: { model: "claude-3-5-sonnet-20240620",
model: "claude-3-5-sonnet-20240620",
max_tokens: 8192,
system: "(see SYSTEM_PROMPT in https://github.com/saoudrizwan/claude-dev/blob/main/src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: userContent }],
tools: "(see tools in https://github.com/saoudrizwan/claude-dev/blob/main/src/ClaudeDev.ts)",
tool_choice: { type: "auto" },
},
})
)
const response = await this.client.messages.create(
{
model: "claude-3-5-sonnet-20240620", // https://docs.anthropic.com/en/docs/about-claude/models
// beta max tokens
max_tokens: 8192, max_tokens: 8192,
system: SYSTEM_PROMPT, system: "(see SYSTEM_PROMPT in https://github.com/saoudrizwan/claude-dev/blob/main/src/ClaudeDev.ts)",
messages: (await this.providerRef.deref()?.getApiConversationHistory()) || [], messages: [{ conversation_history: "..." }, { role: "user", content: userContent }],
tools: tools, tools: "(see tools in https://github.com/saoudrizwan/claude-dev/blob/main/src/ClaudeDev.ts)",
tool_choice: { type: "auto" }, tool_choice: { type: "auto" },
}, },
{ })
// https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers )
headers: { "anthropic-beta": "max-tokens-3-5-sonnet-2024-07-15" }, try {
} const response = await this.attemptApiRequest()
)
this.requestCount++ this.requestCount++
let assistantResponses: Anthropic.Messages.ContentBlock[] = [] let assistantResponses: Anthropic.Messages.ContentBlock[] = []
@@ -674,8 +692,7 @@ export class ClaudeDev {
return { didEndLoop, inputTokens, outputTokens } return { didEndLoop, inputTokens, outputTokens }
} catch (error) { } catch (error) {
// only called if the API request fails (executeTool errors are returned back to claude) // this should never happen since the only thing that can throw an error is the attemptApiRequest, which is wrapped in a try catch that sends an ask where if noButtonTapped, will clear current task and destroy this instance. However to avoid unhandled promise rejection, we will end this loop which will end execution of this instance (see startTask)
this.say("error", `API request failed:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`)
return { didEndLoop: true, inputTokens: 0, outputTokens: 0 } return { didEndLoop: true, inputTokens: 0, outputTokens: 0 }
} }
} }

View File

@@ -24,7 +24,14 @@ export interface ClaudeMessage {
text?: string text?: string
} }
export type ClaudeAsk = "request_limit_reached" | "followup" | "command" | "completion_result" | "tool" export type ClaudeAsk =
| "request_limit_reached"
| "followup"
| "command"
| "completion_result"
| "tool"
| "api_req_failed"
export type ClaudeSay = export type ClaudeSay =
| "task" | "task"
| "error" | "error"
@@ -34,6 +41,7 @@ export type ClaudeSay =
| "command_output" | "command_output"
| "completion_result" | "completion_result"
| "user_feedback" | "user_feedback"
| "api_req_retried"
export interface ClaudeSayTool { export interface ClaudeSayTool {
tool: "editedExistingFile" | "newFileCreated" | "readFile" | "listFiles" tool: "editedExistingFile" | "newFileCreated" | "readFile" | "listFiles"

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"@vscode/webview-ui-toolkit": "^1.4.0", "@vscode/webview-ui-toolkit": "^1.4.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"react-text-truncate": "^0.19.0", "react-text-truncate": "^0.19.0",

View File

@@ -1,17 +1,27 @@
import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "@shared/ExtensionMessage" import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "@shared/ExtensionMessage"
import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react" import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
import React, { useState } from "react" import React from "react"
import { COMMAND_OUTPUT_STRING } from "../utilities/combineCommandSequences" import { COMMAND_OUTPUT_STRING } from "../utilities/combineCommandSequences"
import { SyntaxHighlighterStyle } from "../utilities/getSyntaxHighlighterStyleFromTheme" import { SyntaxHighlighterStyle } from "../utilities/getSyntaxHighlighterStyleFromTheme"
import CodeBlock from "./CodeBlock/CodeBlock" import CodeBlock from "./CodeBlock/CodeBlock"
import Markdown from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
interface ChatRowProps { interface ChatRowProps {
message: ClaudeMessage message: ClaudeMessage
syntaxHighlighterStyle: SyntaxHighlighterStyle syntaxHighlighterStyle: SyntaxHighlighterStyle
isExpanded: boolean
onToggleExpand: () => void
apiRequestFailedMessage?: string
} }
const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) => { const ChatRow: React.FC<ChatRowProps> = ({
const [isExpanded, setIsExpanded] = useState(false) message,
syntaxHighlighterStyle,
isExpanded,
onToggleExpand,
apiRequestFailedMessage,
}) => {
const cost = message.text != null && message.say === "api_req_started" ? JSON.parse(message.text).cost : undefined const cost = message.text != null && message.say === "api_req_started" ? JSON.parse(message.text).cost : undefined
const getIconAndTitle = (type: ClaudeAsk | ClaudeSay | undefined): [JSX.Element | null, JSX.Element | null] => { const getIconAndTitle = (type: ClaudeAsk | ClaudeSay | undefined): [JSX.Element | null, JSX.Element | null] => {
@@ -56,6 +66,10 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
<span <span
className="codicon codicon-check" className="codicon codicon-check"
style={{ color: successColor, marginBottom: "-1.5px" }}></span> style={{ color: successColor, marginBottom: "-1.5px" }}></span>
) : apiRequestFailedMessage ? (
<span
className="codicon codicon-error"
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>
) : ( ) : (
<div <div
style={{ style={{
@@ -70,15 +84,73 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
</div> </div>
</div> </div>
), ),
<span style={{ color: normalColor, fontWeight: "bold" }}> cost ? (
{cost ? "API Request Complete" : "Making API Request..."} <span style={{ color: normalColor, fontWeight: "bold" }}>API Request Complete</span>
</span>, ) : apiRequestFailedMessage ? (
<span style={{ color: errorColor, fontWeight: "bold" }}>API Request Failed</span>
) : (
<span style={{ color: normalColor, fontWeight: "bold" }}>Making API Request...</span>
),
] ]
default: default:
return [null, null] return [null, null]
} }
} }
const convertToMarkdown = (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.
return (
<Markdown
children={markdown}
components={{
p(props) {
const { style, ...rest } = props
return <p style={{ ...style, margin: 0, marginTop: 0, marginBottom: 0 }} {...rest} />
},
//p: "span",
// 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",
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}>
{children}
</code>
)
},
}}
/>
)
}
const renderContent = () => { const renderContent = () => {
const [icon, title] = getIconAndTitle(message.type === "ask" ? message.ask : message.say) const [icon, title] = getIconAndTitle(message.type === "ask" ? message.ask : message.say)
@@ -89,7 +161,7 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
marginBottom: "10px", marginBottom: "10px",
} }
const contentStyle: React.CSSProperties = { const pStyle: React.CSSProperties = {
margin: 0, margin: 0,
whiteSpace: "pre-line", whiteSpace: "pre-line",
} }
@@ -99,24 +171,37 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
switch (message.say) { switch (message.say) {
case "api_req_started": case "api_req_started":
return ( return (
<div style={{ ...headerStyle, marginBottom: 0, justifyContent: "space-between" }}> <>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}> <div
{icon} style={{
{title} ...headerStyle,
{cost && <VSCodeBadge>${Number(cost).toFixed(4)}</VSCodeBadge>} marginBottom: cost == null && apiRequestFailedMessage ? 10 : 0,
justifyContent: "space-between",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
{icon}
{title}
{cost && <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> </div>
<VSCodeButton {cost == null && apiRequestFailedMessage && (
appearance="icon" <p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>
aria-label="Toggle Details" {apiRequestFailedMessage}
onClick={() => setIsExpanded(!isExpanded)}> </p>
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span> )}
</VSCodeButton> </>
</div>
) )
case "api_req_finished": case "api_req_finished":
return null // we should never see this message type return null // we should never see this message type
case "text": case "text":
return <p style={contentStyle}>{message.text}</p> return <div>{convertToMarkdown(message.text)}</div>
case "user_feedback": case "user_feedback":
return ( return (
<div <div
@@ -140,9 +225,7 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
{title} {title}
</div> </div>
)} )}
<p style={{ ...contentStyle, color: "var(--vscode-errorForeground)" }}> <p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
{message.text}
</p>
</> </>
) )
case "completion_result": case "completion_result":
@@ -152,9 +235,9 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
{icon} {icon}
{title} {title}
</div> </div>
<p style={{ ...contentStyle, color: "var(--vscode-testing-iconPassed)" }}> <div style={{ color: "var(--vscode-testing-iconPassed)" }}>
{message.text} {convertToMarkdown(message.text)}
</p> </div>
</> </>
) )
default: default:
@@ -166,7 +249,7 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
{title} {title}
</div> </div>
)} )}
<p style={contentStyle}>{message.text}</p> <div>{convertToMarkdown(message.text)}</div>
</> </>
) )
} }
@@ -192,6 +275,8 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
diff={tool.diff!} diff={tool.diff!}
path={tool.path!} path={tool.path!}
syntaxHighlighterStyle={syntaxHighlighterStyle} syntaxHighlighterStyle={syntaxHighlighterStyle}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/> />
</> </>
) )
@@ -208,6 +293,8 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
code={tool.content!} code={tool.content!}
path={tool.path!} path={tool.path!}
syntaxHighlighterStyle={syntaxHighlighterStyle} syntaxHighlighterStyle={syntaxHighlighterStyle}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/> />
</> </>
) )
@@ -222,6 +309,8 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
code={tool.content!} code={tool.content!}
path={tool.path!} path={tool.path!}
syntaxHighlighterStyle={syntaxHighlighterStyle} syntaxHighlighterStyle={syntaxHighlighterStyle}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/> />
</> </>
) )
@@ -239,6 +328,8 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
path={tool.path!} path={tool.path!}
language="shell-session" language="shell-session"
syntaxHighlighterStyle={syntaxHighlighterStyle} syntaxHighlighterStyle={syntaxHighlighterStyle}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/> />
</> </>
) )
@@ -251,9 +342,7 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
{icon} {icon}
{title} {title}
</div> </div>
<p style={{ ...contentStyle, color: "var(--vscode-errorForeground)" }}> <p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
{message.text}
</p>
</> </>
) )
case "command": case "command":
@@ -275,24 +364,28 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
{icon} {icon}
{title} {title}
</div> </div>
<div style={contentStyle}> <div>
<div> <div>
<CodeBlock <CodeBlock
code={command} code={command}
language="shell-session" language="shell-session"
syntaxHighlighterStyle={syntaxHighlighterStyle} syntaxHighlighterStyle={syntaxHighlighterStyle}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/> />
</div> </div>
{output && ( {output && (
<> <>
<p style={{ ...contentStyle, margin: "10px 0 10px 0" }}> <p style={{ ...pStyle, margin: "10px 0 10px 0" }}>
{COMMAND_OUTPUT_STRING} {COMMAND_OUTPUT_STRING}
</p> </p>
<CodeBlock <CodeBlock
code={output} code={output}
language="shell-session" language="shell-session"
syntaxHighlighterStyle={syntaxHighlighterStyle} syntaxHighlighterStyle={syntaxHighlighterStyle}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/> />
</> </>
)} )}
@@ -307,15 +400,15 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
{icon} {icon}
{title} {title}
</div> </div>
<p style={{ ...contentStyle, color: "var(--vscode-testing-iconPassed)" }}> <div style={{ color: "var(--vscode-testing-iconPassed)" }}>
{message.text} {convertToMarkdown(message.text)}
</p> </div>
</div> </div>
) )
} else { } else {
return null // Don't render anything when we get a completion_result ask without text return null // Don't render anything when we get a completion_result ask without text
} }
default: case "followup":
return ( return (
<> <>
{title && ( {title && (
@@ -324,17 +417,14 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
{title} {title}
</div> </div>
)} )}
<p style={contentStyle}>{message.text}</p> <div>{convertToMarkdown(message.text)}</div>
</> </>
) )
} }
} }
} }
// we need to return null here instead of in getContent since that way would result in padding being applied // 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
if (!shouldShowChatRow(message)) {
return null // Don't render anything for this message type
}
return ( return (
<div <div
@@ -348,6 +438,8 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
code={JSON.stringify(JSON.parse(message.text || "{}").request, null, 2)} code={JSON.stringify(JSON.parse(message.text || "{}").request, null, 2)}
language="json" language="json"
syntaxHighlighterStyle={syntaxHighlighterStyle} syntaxHighlighterStyle={syntaxHighlighterStyle}
isExpanded={true}
onToggleExpand={onToggleExpand}
/> />
</div> </div>
)} )}
@@ -355,18 +447,4 @@ const ChatRow: React.FC<ChatRowProps> = ({ message, syntaxHighlighterStyle }) =>
) )
} }
export const shouldShowChatRow = (message: ClaudeMessage) => {
// combineApiRequests removes this from modifiedMessages anyways
if (message.say === "api_req_finished") {
return false
}
// 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.type === "ask" && message.ask === "completion_result" && message.text === "") {
return false
}
return true
}
export default ChatRow export default ChatRow

View File

@@ -9,7 +9,7 @@ import { combineCommandSequences } from "../utilities/combineCommandSequences"
import { getApiMetrics } from "../utilities/getApiMetrics" import { getApiMetrics } from "../utilities/getApiMetrics"
import { getSyntaxHighlighterStyleFromTheme } from "../utilities/getSyntaxHighlighterStyleFromTheme" import { getSyntaxHighlighterStyleFromTheme } from "../utilities/getSyntaxHighlighterStyleFromTheme"
import { vscode } from "../utilities/vscode" import { vscode } from "../utilities/vscode"
import ChatRow, { shouldShowChatRow } from "./ChatRow" import ChatRow from "./ChatRow"
import TaskHeader from "./TaskHeader" import TaskHeader from "./TaskHeader"
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
@@ -42,6 +42,15 @@ const ChatView = ({ messages, isHidden, vscodeThemeName }: ChatViewProps) => {
const virtuosoRef = useRef<VirtuosoHandle>(null) const virtuosoRef = useRef<VirtuosoHandle>(null)
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
const toggleRowExpansion = (ts: number) => {
setExpandedRows((prev) => ({
...prev,
[ts]: !prev[ts],
}))
}
useEffect(() => { useEffect(() => {
if (!vscodeThemeName) return if (!vscodeThemeName) return
const theme = getSyntaxHighlighterStyleFromTheme(vscodeThemeName) const theme = getSyntaxHighlighterStyleFromTheme(vscodeThemeName)
@@ -68,6 +77,13 @@ const ChatView = ({ messages, isHidden, vscodeThemeName }: ChatViewProps) => {
setPrimaryButtonText("Proceed") setPrimaryButtonText("Proceed")
setSecondaryButtonText("Start New Task") setSecondaryButtonText("Start New Task")
break break
case "api_req_failed":
setTextAreaDisabled(true)
setClaudeAsk("api_req_failed")
setEnableButtons(true)
setPrimaryButtonText("Retry")
setSecondaryButtonText("Start New Task")
break
case "followup": case "followup":
setTextAreaDisabled(false) setTextAreaDisabled(false)
setClaudeAsk("followup") setClaudeAsk("followup")
@@ -170,6 +186,7 @@ const ChatView = ({ messages, isHidden, vscodeThemeName }: ChatViewProps) => {
const handlePrimaryButtonClick = () => { const handlePrimaryButtonClick = () => {
switch (claudeAsk) { switch (claudeAsk) {
case "request_limit_reached": case "request_limit_reached":
case "api_req_failed":
case "command": case "command":
case "tool": case "tool":
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonTapped" }) vscode.postMessage({ type: "askResponse", askResponse: "yesButtonTapped" })
@@ -189,6 +206,7 @@ const ChatView = ({ messages, isHidden, vscodeThemeName }: ChatViewProps) => {
const handleSecondaryButtonClick = () => { const handleSecondaryButtonClick = () => {
switch (claudeAsk) { switch (claudeAsk) {
case "request_limit_reached": case "request_limit_reached":
case "api_req_failed":
startNewTask() startNewTask()
break break
case "command": case "command":
@@ -262,6 +280,41 @@ const ChatView = ({ messages, isHidden, vscodeThemeName }: ChatViewProps) => {
} }
}, [isHidden, textAreaDisabled, enableButtons]) }, [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
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
}
return true
})
}, [modifiedMessages])
useEffect(() => {
// 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?.scrollToIndex({
index: "LAST",
behavior: "smooth",
})
}, 50)
return () => clearTimeout(timer)
}, [visibleMessages])
return ( return (
<div <div
style={{ style={{
@@ -306,18 +359,28 @@ const ChatView = ({ messages, isHidden, vscodeThemeName }: ChatViewProps) => {
flexGrow: 1, flexGrow: 1,
overflowY: "scroll", // always show scrollbar overflowY: "scroll", // always show scrollbar
}} }}
followOutput={(isAtBottom) => { // followOutput={(isAtBottom) => {
// TODO: we can use isAtBottom to prevent scrolling if user is scrolled up, and show a 'scroll to bottom' button for better UX // const lastMessage = modifiedMessages.at(-1)
const lastMessage = modifiedMessages.at(-1) // if (lastMessage && shouldShowChatRow(lastMessage)) {
if (lastMessage && shouldShowChatRow(lastMessage)) { // return "smooth"
return "smooth" // NOTE: scroll to bottom may not work if you use margin, see virtuoso's troubleshooting // }
} // return false
return false // }}
}}
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={modifiedMessages} 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={(index, message) => (
<ChatRow key={message.ts} message={message} syntaxHighlighterStyle={syntaxHighlighterStyle} /> <ChatRow
key={message.ts}
message={message}
syntaxHighlighterStyle={syntaxHighlighterStyle}
isExpanded={expandedRows[message.ts] || false}
onToggleExpand={() => toggleRowExpansion(message.ts)}
apiRequestFailedMessage={
index === visibleMessages.length - 1 && modifiedMessages.at(-1)?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried
? modifiedMessages.at(-1)?.text
: undefined
}
/>
)} )}
/> />
<div <div

View File

@@ -1,5 +1,5 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useMemo, useState } from "react" import { useMemo } from "react"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { getLanguageFromPath } from "../../utilities/getLanguageFromPath" import { getLanguageFromPath } from "../../utilities/getLanguageFromPath"
import { SyntaxHighlighterStyle } from "../../utilities/getSyntaxHighlighterStyleFromTheme" import { SyntaxHighlighterStyle } from "../../utilities/getSyntaxHighlighterStyleFromTheme"
@@ -64,11 +64,19 @@ interface CodeBlockProps {
language?: string | undefined language?: string | undefined
path?: string path?: string
syntaxHighlighterStyle: SyntaxHighlighterStyle syntaxHighlighterStyle: SyntaxHighlighterStyle
isExpanded: boolean
onToggleExpand: () => void
} }
const CodeBlock = ({ code, diff, language, path, syntaxHighlighterStyle }: CodeBlockProps) => { const CodeBlock = ({
const [isExpanded, setIsExpanded] = useState(false) code,
diff,
language,
path,
syntaxHighlighterStyle,
isExpanded,
onToggleExpand,
}: CodeBlockProps) => {
/* /*
We need to remove leading non-alphanumeric characters from the path in order for our leading ellipses trick to work. We need to remove leading non-alphanumeric characters from the path in order for our leading ellipses trick to work.
@@ -100,7 +108,7 @@ const CodeBlock = ({ code, diff, language, path, syntaxHighlighterStyle }: CodeB
padding: "6px 10px", padding: "6px 10px",
cursor: "pointer", cursor: "pointer",
}} }}
onClick={() => setIsExpanded(!isExpanded)}> onClick={onToggleExpand}>
<span <span
style={{ style={{
color: "#BABCC3", color: "#BABCC3",
@@ -115,14 +123,14 @@ const CodeBlock = ({ code, diff, language, path, syntaxHighlighterStyle }: CodeB
}}> }}>
{removeLeadingNonAlphanumeric(path) + "\u200E"} {removeLeadingNonAlphanumeric(path) + "\u200E"}
</span> </span>
<VSCodeButton appearance="icon" aria-label="Toggle Code" onClick={() => setIsExpanded(!isExpanded)}> <VSCodeButton appearance="icon" aria-label="Toggle Code">
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span> <span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
</VSCodeButton> </VSCodeButton>
</div> </div>
)} )}
{(!path || isExpanded) && ( {(!path || isExpanded) && (
<div <div
className="code-block-scrollable" //className="code-block-scrollable" this doesn't seem to be necessary anymore, on silicon macs it shows the native mac scrollbar instead of the vscode styled one
style={{ style={{
overflowX: "auto", overflowX: "auto",
overflowY: "hidden", overflowY: "hidden",

View File

@@ -1,6 +1,6 @@
import { VSCodeButton, VSCodeDivider, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { VSCodeButton, VSCodeDivider, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import React, { useState } from "react" import React, { useState } from "react"
import { useMount } from "react-use" import { useEffectOnce } from "react-use"
import { vscode } from "../utilities/vscode" import { vscode } from "../utilities/vscode"
type SettingsViewProps = { type SettingsViewProps = {
@@ -70,9 +70,13 @@ const SettingsView = ({ apiKey, setApiKey, maxRequestsPerTask, setMaxRequestsPer
If we only want to run code once on mount we can use react-use's useEffectOnce or useMount If we only want to run code once on mount we can use react-use's useEffectOnce or useMount
*/ */
useMount(() => { useEffectOnce(() => {
validateApiKey(apiKey) const timeoutId = setTimeout(() => {
validateMaxRequests(maxRequestsPerTask) validateApiKey(apiKey)
validateMaxRequests(maxRequestsPerTask)
}, 1000)
return () => clearTimeout(timeoutId)
}) })
return ( return (

View File

@@ -34,9 +34,11 @@ body {
} }
body.scrollable, body.scrollable,
.scrollable, body.code-block-scrollable, .code-block-scrollable { .scrollable,
border-color: transparent; body.code-block-scrollable,
transition: border-color 0.7s linear; .code-block-scrollable {
border-color: transparent;
transition: border-color 0.7s linear;
} }
body:hover.scrollable, body:hover.scrollable,
@@ -46,30 +48,29 @@ body:focus-within .scrollable,
body:hover.code-block-scrollable, body:hover.code-block-scrollable,
body:hover .code-block-scrollable, body:hover .code-block-scrollable,
body:focus-within.code-block-scrollable, body:focus-within.code-block-scrollable,
body:focus-within .code-block-scrollable body:focus-within .code-block-scrollable {
{ border-color: var(--vscode-scrollbarSlider-background);
border-color: var(--vscode-scrollbarSlider-background); transition: none;
transition: none;
} }
.scrollable::-webkit-scrollbar-corner { .scrollable::-webkit-scrollbar-corner {
background-color: transparent !important; background-color: transparent !important;
} }
.scrollable::-webkit-scrollbar-thumb { .scrollable::-webkit-scrollbar-thumb {
background-color: transparent; background-color: transparent;
border-color: inherit; border-color: inherit;
border-right-style: inset; border-right-style: inset;
border-right-width: calc(100vw + 100vh); border-right-width: calc(100vw + 100vh);
border-radius: unset !important; border-radius: unset !important;
} }
.scrollable::-webkit-scrollbar-thumb:hover { .scrollable::-webkit-scrollbar-thumb:hover {
border-color: var(--vscode-scrollbarSlider-hoverBackground); border-color: var(--vscode-scrollbarSlider-hoverBackground);
} }
.scrollable::-webkit-scrollbar-thumb:active { .scrollable::-webkit-scrollbar-thumb:active {
border-color: var(--vscode-scrollbarSlider-activeBackground); border-color: var(--vscode-scrollbarSlider-activeBackground);
} }
/* /*
@@ -87,24 +88,24 @@ The above scrollbar styling uses some transparent background color magic to acco
*/ */
.code-block-scrollable::-webkit-scrollbar-track { .code-block-scrollable::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.code-block-scrollable::-webkit-scrollbar-thumb { .code-block-scrollable::-webkit-scrollbar-thumb {
background-color: var(--vscode-scrollbarSlider-background); background-color: var(--vscode-scrollbarSlider-background);
border-radius: 5px; border-radius: 5px;
border: 2px solid transparent; border: 2px solid transparent;
background-clip: content-box; background-clip: content-box;
} }
.code-block-scrollable::-webkit-scrollbar-thumb:hover { .code-block-scrollable::-webkit-scrollbar-thumb:hover {
background-color: var(--vscode-scrollbarSlider-hoverBackground); background-color: var(--vscode-scrollbarSlider-hoverBackground);
} }
.code-block-scrollable::-webkit-scrollbar-thumb:active { .code-block-scrollable::-webkit-scrollbar-thumb:active {
background-color: var(--vscode-scrollbarSlider-activeBackground); background-color: var(--vscode-scrollbarSlider-activeBackground);
} }
.code-block-scrollable::-webkit-scrollbar-corner { .code-block-scrollable::-webkit-scrollbar-corner {
background-color: transparent; background-color: transparent;
} }