Implement bidirectional extension webview messaging system; extension holds claude messages state to keep webview stateless

This commit is contained in:
Saoud Rizwan
2024-07-08 12:58:05 -04:00
parent 09559c314b
commit 4da785b822
9 changed files with 242 additions and 79 deletions

28
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"execa": "^9.3.0", "execa": "^9.3.0",
"glob": "^10.4.3", "glob": "^10.4.3",
"os-name": "^6.0.0", "os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
"serialize-error": "^11.0.3" "serialize-error": "^11.0.3"
}, },
"devDependencies": { "devDependencies": {
@@ -4465,6 +4466,33 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-timeout": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.2.tgz",
"integrity": "sha512-UbD77BuZ9Bc9aABo74gfXhNvzC9Tx7SxtHSh1fxvx3jTLLYvmVhiQZZrJzqqU0jKbN32kb5VOKiLEQI/3bIjgQ==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-wait-for": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/p-wait-for/-/p-wait-for-5.0.2.tgz",
"integrity": "sha512-lwx6u1CotQYPVju77R+D0vFomni/AqRfqLmqQ8hekklqZ6gAY9rONh7lBQ0uxWMkC2AuX9b2DVAl8To0NyP1JA==",
"license": "MIT",
"dependencies": {
"p-timeout": "^6.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",

View File

@@ -97,6 +97,7 @@
"execa": "^9.3.0", "execa": "^9.3.0",
"glob": "^10.4.3", "glob": "^10.4.3",
"os-name": "^6.0.0", "os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
"serialize-error": "^11.0.3" "serialize-error": "^11.0.3"
} }
} }

View File

@@ -9,6 +9,12 @@ import * as path from "path"
import { serializeError } from "serialize-error" import { serializeError } from "serialize-error"
import { DEFAULT_MAX_REQUESTS_PER_TASK } from "./shared/Constants" import { DEFAULT_MAX_REQUESTS_PER_TASK } from "./shared/Constants"
import { Tool, ToolName } from "./shared/Tool" import { Tool, ToolName } from "./shared/Tool"
import { ClaudeAsk, ClaudeSay, ExtensionMessage } from "./shared/ExtensionMessage"
import * as vscode from "vscode"
import pWaitFor from 'p-wait-for'
import { ClaudeAskResponse } from "./shared/WebviewMessage"
import { SidebarProvider } from "./providers/SidebarProvider"
import { ClaudeRequestResult } from "./shared/ClaudeRequestResult"
const SYSTEM_PROMPT = `You are Claude Dev, a highly skilled software developer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices. const SYSTEM_PROMPT = `You are Claude Dev, a highly skilled software developer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
@@ -153,32 +159,53 @@ export class ClaudeDev {
private conversationHistory: Anthropic.MessageParam[] = [] private conversationHistory: Anthropic.MessageParam[] = []
private maxRequestsPerTask: number private maxRequestsPerTask: number
private requestCount = 0 private requestCount = 0
private askResponse?: ClaudeAskResponse
private askResponseText?: string
private providerRef: WeakRef<SidebarProvider>
constructor(apiKey: string, maxRequestsPerTask?: number) { constructor(provider: SidebarProvider, task: string, apiKey: string, maxRequestsPerTask?: number) {
this.providerRef = new WeakRef(provider)
this.client = new Anthropic({ apiKey }) this.client = new Anthropic({ apiKey })
this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK
// conversationHistory (for API) and claudeMessages (for webview) need to be in sync
// if the extension process were killed, then on restart the claudeMessages might not be empty, so we need to set it to [] when we create a new ClaudeDev client (otherwise webview would show stale messages from previous session)
this.providerRef.deref()?.setClaudeMessages([])
this.startTask(task)
} }
updateApiKey(apiKey: string) { updateApiKey(apiKey: string) {
this.client = new Anthropic({ apiKey }) this.client = new Anthropic({ apiKey })
} }
updateMaxRequestsPerTask(maxRequestsPerTask: number) { updateMaxRequestsPerTask(maxRequestsPerTask: number | undefined) {
this.maxRequestsPerTask = maxRequestsPerTask this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK
} }
async ask(type: "request_limit_reached" | "followup" | "command" | "completion_result", question: string): Promise<string> { async handleWebviewAskResponse(askResponse: ClaudeAskResponse, text?: string) {
return "" this.askResponse = askResponse
this.askResponseText = text
}
async ask(type: ClaudeAsk, question: string): Promise<{response: ClaudeAskResponse, text?: string}> {
this.askResponse = undefined
this.askResponseText = undefined
await this.providerRef.deref()?.addClaudeMessage({ type: "ask", ask: type, text: question })
await this.providerRef.deref()?.postStateToWebview()
await pWaitFor(() => this.askResponse !== undefined, { interval: 100 })
const result = { response: this.askResponse!, text: this.askResponseText }
this.askResponse = undefined
this.askResponseText = undefined
return result
} }
async say(type: "error" | "api_cost" | "text" | "tool" | "command_output" | "completed", question: string): Promise<undefined> { async say(type: ClaudeSay, question: string): Promise<undefined> {
// send message asyncronously await this.providerRef.deref()?.addClaudeMessage({ type: "say", say: type, text: question })
return await this.providerRef.deref()?.postStateToWebview()
} }
async startNewTask(task: string): Promise<void> { private async startTask(task: string): Promise<void> {
this.conversationHistory = []
this.requestCount = 0
// Get all relevant context for the task // Get all relevant context for the task
const filesInCurrentDir = await this.listFiles() const filesInCurrentDir = await this.listFiles()
@@ -209,7 +236,7 @@ ${filesInCurrentDir}`
const totalCost = this.calculateApiCost(totalInputTokens, totalOutputTokens) const totalCost = this.calculateApiCost(totalInputTokens, totalOutputTokens)
if (didCompleteTask) { if (didCompleteTask) {
this.say("completed", `Task completed. Total API usage cost: ${totalCost}`) this.say("task_completed", `Task completed. Total API usage cost: ${totalCost}`)
break break
} else { } else {
this.say( this.say(
@@ -319,8 +346,9 @@ ${filesInCurrentDir}`
mark: true, // Append a / on any directories matched mark: true, // Append a / on any directories matched
} }
// * globs all files in one dir, ** globs files in nested directories // * globs all files in one dir, ** globs files in nested directories
const entries = await glob("**", options) //const entries = await glob("**", options)
return entries.slice(1, 501).join("\n") // truncate to 500 entries (removes first entry which is the directory itself) // FIXME: instead of using glob to read all files, we will use vscode api to get workspace files list. (otherwise this prompts user to give permissions to read files if e.g. it was opened at root directory)
return ["index.ts"].slice(1, 501).join("\n") // truncate to 500 entries (removes first entry which is the directory itself)
} catch (error) { } catch (error) {
const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}` const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}`
this.say("error", errorString) this.say("error", errorString)
@@ -329,8 +357,8 @@ ${filesInCurrentDir}`
} }
async executeCommand(command: string): Promise<string> { async executeCommand(command: string): Promise<string> {
const answer = await this.ask("command", `Claude wants to execute the following command:\n${command}\nDo you approve? (yes/no):`) const { response } = await this.ask("command", `Claude wants to execute the following command:\n${command}\nDo you approve?`)
if (answer.toLowerCase() !== "yes") { if (response === "noButtonTapped") {
return "Command execution was not approved by the user." return "Command execution was not approved by the user."
} }
try { try {
@@ -353,17 +381,17 @@ ${filesInCurrentDir}`
} }
async askFollowupQuestion(question: string): Promise<string> { async askFollowupQuestion(question: string): Promise<string> {
const answer = await this.ask("followup", question) const { text } = await this.ask("followup", question)
return `User's response:\n\"${answer}\"` return `User's response:\n\"${text}\"`
} }
async attemptCompletion(result: string): Promise<string> { async attemptCompletion(result: string): Promise<string> {
const feedback = await this.ask("completion_result", result) const { response, text } = await this.ask("completion_result", result)
// Are you satisfied with the result(yes/if no then provide feedback): // Are you satisfied with the result(yes/if no then provide feedback):
if (feedback.toLowerCase() === "yes") { if (response === "yesButtonTapped") {
return "" return ""
} }
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\"${feedback}\"` 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 recursivelyMakeClaudeRequests( async recursivelyMakeClaudeRequests(
@@ -376,12 +404,12 @@ ${filesInCurrentDir}`
): Promise<ClaudeRequestResult> { ): Promise<ClaudeRequestResult> {
this.conversationHistory.push({ role: "user", content: userContent }) this.conversationHistory.push({ role: "user", content: userContent })
if (this.requestCount >= this.maxRequestsPerTask) { if (this.requestCount >= this.maxRequestsPerTask) {
const answer = await this.ask( const { response } = await this.ask(
"request_limit_reached", "request_limit_reached",
`\nClaude has exceeded ${this.maxRequestsPerTask} requests for this task! Would you like to reset the count and proceed? (yes/no):` `\nClaude has exceeded ${this.maxRequestsPerTask} requests for this task! Would you like to reset the count and proceed?:`
) )
if (answer.toLowerCase() === "yes") { if (response === "yesButtonTapped") {
this.requestCount = 0 this.requestCount = 0
} else { } else {
this.conversationHistory.push({ this.conversationHistory.push({

View File

@@ -1,8 +1,9 @@
import { Uri, Webview } from "vscode" import { Uri, Webview } from "vscode"
//import * as weather from "weather-js" //import * as weather from "weather-js"
import * as vscode from "vscode" import * as vscode from "vscode"
import { ExtensionMessage } from "../shared/ExtensionMessage" import { ClaudeMessage, ExtensionMessage } from "../shared/ExtensionMessage"
import { WebviewMessage } from "../shared/WebviewMessage" import { WebviewMessage } from "../shared/WebviewMessage"
import { ClaudeDev } from "../ClaudeDev"
/* /*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -12,11 +13,13 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
type ExtensionSecretKey = "apiKey" type ExtensionSecretKey = "apiKey"
type ExtensionGlobalStateKey = "didOpenOnce" | "maxRequestsPerTask" type ExtensionGlobalStateKey = "didOpenOnce" | "maxRequestsPerTask"
type ExtensionWorkspaceStateKey = "claudeMessages" | "apiConversationHistory"
export class SidebarProvider implements vscode.WebviewViewProvider { export class SidebarProvider implements vscode.WebviewViewProvider {
public static readonly viewType = "claude-dev.SidebarProvider" public static readonly viewType = "claude-dev.SidebarProvider"
private _view?: vscode.WebviewView private view?: vscode.WebviewView
private claudeDev?: ClaudeDev
constructor(private readonly context: vscode.ExtensionContext) {} constructor(private readonly context: vscode.ExtensionContext) {}
@@ -25,7 +28,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
context: vscode.WebviewViewResolveContext<unknown>, context: vscode.WebviewViewResolveContext<unknown>,
token: vscode.CancellationToken token: vscode.CancellationToken
): void | Thenable<void> { ): void | Thenable<void> {
this._view = webviewView this.view = webviewView
webviewView.webview.options = { webviewView.webview.options = {
// Allow scripts in the webview // Allow scripts in the webview
@@ -36,12 +39,22 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
// Sets up an event listener to listen for messages passed from the webview view context // Sets up an event listener to listen for messages passed from the webview view context
// and executes code based on the message that is recieved // and executes code based on the message that is recieved
this._setWebviewMessageListener(webviewView.webview) this.setWebviewMessageListener(webviewView.webview)
}
async tryToInitClaudeDevWithTask(task: string) {
const [apiKey, maxRequestsPerTask] = await Promise.all([
this.getSecret("apiKey") as Promise<string | undefined>,
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
])
if (this.view && apiKey) {
this.claudeDev = new ClaudeDev(this, task, apiKey, maxRequestsPerTask)
}
} }
// Send any JSON serializable data to the react app // Send any JSON serializable data to the react app
postMessageToWebview(message: ExtensionMessage) { async postMessageToWebview(message: ExtensionMessage) {
this._view?.webview.postMessage(message) await this.view?.webview.postMessage(message)
} }
/** /**
@@ -131,24 +144,27 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
* @param webview A reference to the extension webview * @param webview A reference to the extension webview
* @param context A reference to the extension context * @param context A reference to the extension context
*/ */
private _setWebviewMessageListener(webview: vscode.Webview) { private setWebviewMessageListener(webview: vscode.Webview) {
webview.onDidReceiveMessage(async (message: WebviewMessage) => { webview.onDidReceiveMessage(async (message: WebviewMessage) => {
switch (message.type) { switch (message.type) {
case "webviewDidLaunch": case "webviewDidLaunch":
await this.updateGlobalState("didOpenOnce", true) await this.updateGlobalState("didOpenOnce", true)
await this.postStateToWebview() await this.postStateToWebview()
break break
case "text": case "newTask":
// Code that should run in response to the hello message command // Code that should run in response to the hello message command
vscode.window.showInformationMessage(message.text!) //vscode.window.showInformationMessage(message.text!)
// Send a message to our webview. // Send a message to our webview.
// You can send any JSON serializable data. // You can send any JSON serializable data.
// Could also do this in extension .ts // Could also do this in extension .ts
this.postMessageToWebview({ type: "text", text: `Extension: ${Date.now()}` }) //this.postMessageToWebview({ type: "text", text: `Extension: ${Date.now()}` })
// initializing new instance of ClaudeDev will make sure that any agentically running promises in old instance don't affect our new task. this essentially creates a fresh slate for the new task
await this.tryToInitClaudeDevWithTask(message.text!)
break break
case "apiKey": case "apiKey":
await this.storeSecret("apiKey", message.text!) await this.storeSecret("apiKey", message.text!)
this.claudeDev?.updateApiKey(message.text!)
await this.postStateToWebview() await this.postStateToWebview()
break break
case "maxRequestsPerTask": case "maxRequestsPerTask":
@@ -160,32 +176,74 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
} }
} }
await this.updateGlobalState("maxRequestsPerTask", result) await this.updateGlobalState("maxRequestsPerTask", result)
this.claudeDev?.updateMaxRequestsPerTask(result)
await this.postStateToWebview() await this.postStateToWebview()
break break
case "askResponse":
this.claudeDev?.handleWebviewAskResponse(message.askResponse!, message.text)
// Add more switch case statements here as more webview message commands // Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js) // are created within the webview context (i.e. inside media/main.js)
} }
}) })
} }
private async postStateToWebview() { async postStateToWebview() {
const [didOpenOnce, apiKey, maxRequestsPerTask] = await Promise.all([ const [didOpenOnce, apiKey, maxRequestsPerTask, claudeMessages] = await Promise.all([
this.getGlobalState("didOpenOnce") as Promise<boolean | undefined>, this.getGlobalState("didOpenOnce") as Promise<boolean | undefined>,
this.getSecret("apiKey") as Promise<string | undefined>, this.getSecret("apiKey") as Promise<string | undefined>,
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>, this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
this.getClaudeMessages(),
]) ])
this.postMessageToWebview({ this.postMessageToWebview({
type: "state", type: "state",
state: { didOpenOnce: !!didOpenOnce, apiKey: apiKey, maxRequestsPerTask: maxRequestsPerTask }, state: { didOpenOnce: !!didOpenOnce, apiKey, maxRequestsPerTask, claudeMessages },
}) })
} }
// client messages
async getClaudeMessages(): Promise<ClaudeMessage[]> {
const messages = (await this.getWorkspaceState("claudeMessages")) as ClaudeMessage[]
return messages || []
}
async setClaudeMessages(messages: ClaudeMessage[] | undefined) {
await this.updateWorkspaceState("claudeMessages", messages)
}
async addClaudeMessage(message: ClaudeMessage): Promise<ClaudeMessage[]> {
const messages = await this.getClaudeMessages()
messages.push(message)
await this.setClaudeMessages(messages)
return messages
}
// api conversation history
async getApiConversationHistory(): Promise<ClaudeMessage[]> {
const messages = (await this.getWorkspaceState("apiConversationHistory")) as ClaudeMessage[]
return messages || []
}
async setApiConversationHistory(messages: ClaudeMessage[] | undefined) {
await this.updateWorkspaceState("apiConversationHistory", messages)
}
async addMessageToApiConversationHistory(message: ClaudeMessage): Promise<ClaudeMessage[]> {
const messages = await this.getClaudeMessages()
messages.push(message)
await this.setClaudeMessages(messages)
return messages
}
/* /*
Storage Storage
https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
https://www.eliostruyf.com/devhack-code-extension-storage-options/ https://www.eliostruyf.com/devhack-code-extension-storage-options/
*/ */
// global
private async updateGlobalState(key: ExtensionGlobalStateKey, value: any) { private async updateGlobalState(key: ExtensionGlobalStateKey, value: any) {
await this.context.globalState.update(key, value) await this.context.globalState.update(key, value)
} }
@@ -194,6 +252,18 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
return await this.context.globalState.get(key) return await this.context.globalState.get(key)
} }
// workspace
private async updateWorkspaceState(key: ExtensionWorkspaceStateKey, value: any) {
await this.context.workspaceState.update(key, value)
}
private async getWorkspaceState(key: ExtensionWorkspaceStateKey) {
return await this.context.workspaceState.get(key)
}
// secrets
private async storeSecret(key: ExtensionSecretKey, value: any) { private async storeSecret(key: ExtensionSecretKey, value: any) {
await this.context.secrets.store(key, value) await this.context.secrets.store(key, value)
} }

View File

@@ -1,4 +1,4 @@
interface ClaudeRequestResult { export interface ClaudeRequestResult {
didCompleteTask: boolean didCompleteTask: boolean
inputTokens: number inputTokens: number
outputTokens: number outputTokens: number

View File

@@ -2,8 +2,18 @@
// webview will hold state // webview will hold state
export interface ExtensionMessage { export interface ExtensionMessage {
type: "text" | "action" | "state" type: "action" | "state"
text?: string text?: string
action?: "plusButtonTapped" | "settingsButtonTapped" action?: "plusButtonTapped" | "settingsButtonTapped"
state?: { didOpenOnce: boolean, apiKey?: string, maxRequestsPerTask?: number } state?: { didOpenOnce: boolean, apiKey?: string, maxRequestsPerTask?: number, claudeMessages: ClaudeMessage[] }
} }
export interface ClaudeMessage {
type: "ask" | "say"
ask?: ClaudeAsk
say?: ClaudeSay
text?: string
}
export type ClaudeAsk = "request_limit_reached" | "followup" | "command" | "completion_result"
export type ClaudeSay = "error" | "api_cost" | "text" | "tool" | "command_output" | "task_completed"

View File

@@ -1,5 +1,7 @@
export interface WebviewMessage { export interface WebviewMessage {
type: "text" | "action" | "apiKey" | "maxRequestsPerTask" | "webviewDidLaunch" type: "apiKey" | "maxRequestsPerTask" | "webviewDidLaunch" | "newTask" | "askResponse"
text?: string text?: string
action?: "newTaskButtonTapped" | "yesButtonTapped" | "noButtonTapped" | "executeButtonTapped" askResponse?: ClaudeAskResponse
} }
export type ClaudeAskResponse = "newTaskButtonTapped" | "yesButtonTapped" | "noButtonTapped" | "textResponse"

View File

@@ -3,7 +3,7 @@ import "./App.css"
import ChatView from "./components/ChatView" import ChatView from "./components/ChatView"
import SettingsView from "./components/SettingsView" import SettingsView from "./components/SettingsView"
import { ExtensionMessage } from "@shared/ExtensionMessage" import { ClaudeMessage, ExtensionMessage } from "@shared/ExtensionMessage"
import WelcomeView from "./components/WelcomeView" import WelcomeView from "./components/WelcomeView"
import { vscode } from "./utilities/vscode" import { vscode } from "./utilities/vscode"
@@ -20,6 +20,7 @@ const App: React.FC = () => {
const [showWelcome, setShowWelcome] = useState<boolean>(false) const [showWelcome, setShowWelcome] = useState<boolean>(false)
const [apiKey, setApiKey] = useState<string>("") const [apiKey, setApiKey] = useState<string>("")
const [maxRequestsPerTask, setMaxRequestsPerTask] = useState<string>("") const [maxRequestsPerTask, setMaxRequestsPerTask] = useState<string>("")
const [claudeMessages, setClaudeMessages] = useState<ClaudeMessage[]>([])
useEffect(() => { useEffect(() => {
vscode.postMessage({ type: "webviewDidLaunch" }) vscode.postMessage({ type: "webviewDidLaunch" })
@@ -36,6 +37,7 @@ const App: React.FC = () => {
? message.state!.maxRequestsPerTask.toString() ? message.state!.maxRequestsPerTask.toString()
: "" : ""
) )
setClaudeMessages(message.state!.claudeMessages)
break break
case "action": case "action":
switch (message.action!) { switch (message.action!) {
@@ -64,7 +66,7 @@ const App: React.FC = () => {
onDone={() => setShowSettings(false)} onDone={() => setShowSettings(false)}
/> />
) : ( ) : (
<ChatView /> <ChatView messages={claudeMessages} />
)} )}
</> </>
) )

View File

@@ -1,42 +1,75 @@
import { ExtensionMessage } from "@shared/ExtensionMessage" import { ClaudeAsk, ClaudeMessage, ExtensionMessage } from "@shared/ExtensionMessage"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { KeyboardEvent, useEffect, useRef, useState } from "react" import { KeyboardEvent, useEffect, useRef, useState } from "react"
import DynamicTextArea from "react-textarea-autosize" import DynamicTextArea from "react-textarea-autosize"
import { vscode } from "../utilities/vscode" import { vscode } from "../utilities/vscode"
import { ClaudeAskResponse } from "@shared/WebviewMessage"
interface Message { interface ChatViewProps {
id: string messages: ClaudeMessage[]
text: string
sender: "user" | "assistant"
} }
// maybe instead of storing state in App, just make chatview always show so dont conditionally load/unload? need to make sure messages are persisted (i remember seeing something about how webviews can be frozen in docs)
const ChatView = () => { const ChatView = ({ messages}: ChatViewProps) => {
const [messages, setMessages] = useState<Message[]>([])
const [inputValue, setInputValue] = useState("") const [inputValue, setInputValue] = useState("")
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const textAreaRef = useRef<HTMLTextAreaElement>(null) const textAreaRef = useRef<HTMLTextAreaElement>(null)
const [textAreaHeight, setTextAreaHeight] = useState<number | undefined>(undefined) const [textAreaHeight, setTextAreaHeight] = useState<number | undefined>(undefined)
const [textAreaDisabled, setTextAreaDisabled] = useState(false)
const [claudeAsk, setClaudeAsk] = useState<ClaudeAsk | undefined>(undefined)
const scrollToBottom = () => { const scrollToBottom = () => {
// https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move // https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: 'nearest', inline: 'start' }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: 'nearest', inline: 'start' })
} }
useEffect(scrollToBottom, [messages]) useEffect(() => {
scrollToBottom()
// if last message is an ask, show user ask UI
// if user finished a task, then start a new task with a new conversation history since in this moment that the extension is waiting for user response, the user could close the extension and the conversation history would be lost.
// basically as long as a task is active, the conversation history will be persisted
const lastMessage = messages.at(-1)
if (lastMessage) {
if (lastMessage.type === "ask") {
setClaudeAsk(lastMessage.ask)
//setTextAreaDisabled(false) // should enable for certain asks
} else {
setClaudeAsk(undefined)
//setTextAreaDisabled(true)
}
}
}, [messages])
const handleSendMessage = () => { const handleSendMessage = () => {
if (inputValue.trim()) { const text = inputValue.trim()
const newMessage: Message = { if (text) {
id: `${Date.now()}-user`,
text: inputValue.trim(),
sender: "user",
}
setMessages(currentMessages => [...currentMessages, newMessage])
setInputValue("") setInputValue("")
// Here you would typically send the message to your extension's backend if (messages.length === 0) {
vscode.postMessage({ type: "text", text: newMessage.text})
vscode.postMessage({ type: "newTask", text })
} else if (claudeAsk) {
switch (claudeAsk) {
case "followup":
vscode.postMessage({ type: "askResponse", askResponse: "textResponse", text })
break
// case "completion_result":
// vscode.postMessage({ type: "askResponse", text })
// break
default:
// for now we'll type the askResponses
vscode.postMessage({ type: "askResponse", askResponse: text as ClaudeAskResponse })
break
}
}
} }
} }
// handle ask buttons
// be sure to setInputValue("")
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault() event.preventDefault()
@@ -48,33 +81,21 @@ const ChatView = () => {
if (textAreaRef.current && !textAreaHeight) { if (textAreaRef.current && !textAreaHeight) {
setTextAreaHeight(textAreaRef.current.offsetHeight) setTextAreaHeight(textAreaRef.current.offsetHeight)
} }
window.addEventListener("message", (e: MessageEvent) => {
const message: ExtensionMessage = e.data
if (message.type === "text") {
const newMessage: Message = {
id: `${Date.now()}-assistant`,
text: message.text!.trim(),
sender: "assistant",
}
setMessages(currentMessages => [...currentMessages, newMessage])
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
return ( return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", overflow: "hidden" }}> <div style={{ display: "flex", flexDirection: "column", height: "100vh", overflow: "hidden" }}>
<div style={{ flexGrow: 1, overflowY: "scroll", scrollbarWidth: "none" }}> <div style={{ flexGrow: 1, overflowY: "scroll", scrollbarWidth: "none" }}>
{messages.map((message) => ( {messages.map((message, index) => (
<div <div
key={message.id} key={index}
style={{ style={{
marginBottom: "10px", marginBottom: "10px",
padding: "8px", padding: "8px",
borderRadius: "4px", borderRadius: "4px",
backgroundColor: backgroundColor:
message.sender === "user" message.type === "ask"
? "var(--vscode-editor-background)" ? "var(--vscode-editor-background)"
: "var(--vscode-sideBar-background)", : "var(--vscode-sideBar-background)",
}}> }}>
@@ -87,6 +108,7 @@ const ChatView = () => {
<DynamicTextArea <DynamicTextArea
ref={textAreaRef} ref={textAreaRef}
value={inputValue} value={inputValue}
disabled={textAreaDisabled}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onHeightChange={() => scrollToBottom()} onHeightChange={() => scrollToBottom()}