Add secret and global state management + messaging between web and extension; persist API key and max num requests; add Welcome screen

This commit is contained in:
Saoud Rizwan
2024-07-07 11:22:16 -04:00
parent 991ea6bd4e
commit 8ba1be1167
10 changed files with 329 additions and 36 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -17,7 +17,7 @@
{ {
"id": "claude-dev-ActivityBar", "id": "claude-dev-ActivityBar",
"title": "Claude Dev", "title": "Claude Dev",
"icon": "assets/icon.png" "icon": "$(robot)"
} }
] ]
}, },

View File

@@ -29,7 +29,7 @@ export function activate(context: vscode.ExtensionContext) {
// }) // })
// context.subscriptions.push(disposable) // context.subscriptions.push(disposable)
const provider = new SidebarProvider(context.extensionUri) const provider = new SidebarProvider(context)
context.subscriptions.push(vscode.window.registerWebviewViewProvider(SidebarProvider.viewType, provider)) context.subscriptions.push(vscode.window.registerWebviewViewProvider(SidebarProvider.viewType, provider))

View File

@@ -11,13 +11,15 @@ https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default
https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
*/ */
type ExtensionSecretKey = "apiKey"
type ExtensionGlobalStateKey = "didOpenOnce" | "maxRequestsPerTask"
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
constructor(private readonly _extensionUri: vscode.Uri) {} constructor(private readonly context: vscode.ExtensionContext) {}
resolveWebviewView( resolveWebviewView(
webviewView: vscode.WebviewView, webviewView: vscode.WebviewView,
@@ -29,7 +31,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
webviewView.webview.options = { webviewView.webview.options = {
// Allow scripts in the webview // Allow scripts in the webview
enableScripts: true, enableScripts: true,
localResourceRoots: [this._extensionUri], localResourceRoots: [this.context.extensionUri],
} }
webviewView.webview.html = this.getHtmlContent(webviewView.webview) webviewView.webview.html = this.getHtmlContent(webviewView.webview)
@@ -59,15 +61,27 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
// then convert it to a uri we can use in the webview. // then convert it to a uri we can use in the webview.
// The CSS file from the React build output // The CSS file from the React build output
const stylesUri = getUri(webview, this._extensionUri, ["webview-ui", "build", "static", "css", "main.css"]) const stylesUri = getUri(webview, this.context.extensionUri, [
"webview-ui",
"build",
"static",
"css",
"main.css",
])
// The JS file from the React build output // The JS file from the React build output
const scriptUri = getUri(webview, this._extensionUri, ["webview-ui", "build", "static", "js", "main.js"]) const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "static", "js", "main.js"])
// The codicon font from the React build output // The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts // https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it // we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
// don't forget to add font-src ${webview.cspSource}; // don't forget to add font-src ${webview.cspSource};
const codiconsUri = getUri(webview, this._extensionUri, ["node_modules", "@vscode", "codicons", "dist", "codicon.css"]) const codiconsUri = getUri(webview, this.context.extensionUri, [
"node_modules",
"@vscode",
"codicons",
"dist",
"codicon.css",
])
// const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js")) // const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js"))
@@ -78,7 +92,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
// const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.css")) // const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.css"))
// Use a nonce to only allow a specific script to be run. // Use a nonce to only allow a specific script to be run.
/* /*
content security policy of your webview to only allow scripts that have a specific nonce content security policy of your webview to only allow scripts that have a specific nonce
create a content security policy meta tag so that only loading scripts with a nonce is allowed create a content security policy meta tag so that only loading scripts with a nonce is allowed
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g. As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g.
@@ -119,20 +133,73 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
* @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((message: WebviewMessage) => { webview.onDidReceiveMessage(async (message: WebviewMessage) => {
switch (message.type) { switch (message.type) {
case "webviewDidLaunch":
await this.updateGlobalState("didOpenOnce", true)
await this.postWebviewState()
break
case "text": case "text":
// 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()}` })
return break
case "apiKey":
await this.storeSecret("apiKey", message.text!)
await this.postWebviewState()
break
case "maxRequestsPerTask":
let result: number | undefined = undefined
if (message.text && message.text.trim()) {
const num = Number(message.text)
if (!isNaN(num)) {
result = num
}
}
await this.updateGlobalState("maxRequestsPerTask", result)
await this.postWebviewState()
break
// 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 postWebviewState() {
const [didOpenOnce, apiKey, maxRequestsPerTask] = await Promise.all([
this.getGlobalState("didOpenOnce") as Promise<boolean | undefined>,
this.getSecret("apiKey") as Promise<string | undefined>,
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
])
this.postMessageToWebview({
type: "webviewState",
webviewState: { didOpenOnce: !!didOpenOnce, apiKey: apiKey, maxRequestsPerTask: maxRequestsPerTask },
})
}
/*
Storage
https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
https://www.eliostruyf.com/devhack-code-extension-storage-options/
*/
private async updateGlobalState(key: ExtensionGlobalStateKey, value: any) {
await this.context.globalState.update(key, value)
}
private async getGlobalState(key: ExtensionGlobalStateKey) {
return await this.context.globalState.get(key)
}
private async storeSecret(key: ExtensionSecretKey, value: any) {
await this.context.secrets.store(key, value)
}
private async getSecret(key: ExtensionSecretKey) {
return await this.context.secrets.get(key)
}
} }

View File

@@ -2,7 +2,8 @@
// webview will hold state // webview will hold state
export interface ExtensionMessage { export interface ExtensionMessage {
type: "text" | "action" type: "text" | "action" | "webviewState"
text?: string text?: string
action?: "plusButtonTapped" | "settingsButtonTapped" action?: "plusButtonTapped" | "settingsButtonTapped"
webviewState?: { didOpenOnce: boolean, apiKey?: string, maxRequestsPerTask?: number }
} }

View File

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

View File

@@ -4,27 +4,70 @@ import "./App.css"
import ChatSidebar from "./components/ChatSidebar" import ChatSidebar from "./components/ChatSidebar"
import SettingsView from "./components/SettingsView" import SettingsView from "./components/SettingsView"
import { ExtensionMessage } from "@shared/ExtensionMessage" import { ExtensionMessage } from "@shared/ExtensionMessage"
import WelcomeView from "./components/WelcomeView"
import { vscode } from "./utilities/vscode"
/*
The contents of webviews however are created when the webview becomes visible and destroyed when the webview is moved into the background. Any state inside the webview will be lost when the webview is moved to a background tab.
The best way to solve this is to make your webview stateless. Use message passing to save off the webview's state and then restore the state when the webview becomes visible again.
*/
const App: React.FC = () => { const App: React.FC = () => {
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [showWelcome, setShowWelcome] = useState<boolean>(false)
const [apiKey, setApiKey] = useState<string>("")
const [maxRequestsPerTask, setMaxRequestsPerTask] = useState<string>("")
useEffect(() => { useEffect(() => {
vscode.postMessage({ type: "webviewDidLaunch" })
window.addEventListener("message", (e: MessageEvent) => { window.addEventListener("message", (e: MessageEvent) => {
const message: ExtensionMessage = e.data const message: ExtensionMessage = e.data
if (message.type === "action") { // switch message.type
switch (message.action!) { switch (message.type) {
case "settingsButtonTapped": case "webviewState":
setShowSettings(true) const shouldShowWelcome = !message.webviewState!.didOpenOnce || !message.webviewState!.apiKey
break setShowWelcome(shouldShowWelcome)
case "plusButtonTapped": setApiKey(message.webviewState!.apiKey || "")
setShowSettings(false) setMaxRequestsPerTask(
break message.webviewState!.maxRequestsPerTask !== undefined
} ? message.webviewState!.maxRequestsPerTask.toString()
: ""
)
break
case "action":
switch (message.action!) {
case "settingsButtonTapped":
setShowSettings(true)
break
case "plusButtonTapped":
setShowSettings(false)
break
}
break
} }
}) })
}, []) }, [])
return <>{showSettings ? <SettingsView /> : <ChatSidebar />}</> return (
<>
{showWelcome ? (
<WelcomeView apiKey={apiKey} setApiKey={setApiKey} />
) : showSettings ? (
<SettingsView
apiKey={apiKey}
setApiKey={setApiKey}
maxRequestsPerTask={maxRequestsPerTask}
setMaxRequestsPerTask={setMaxRequestsPerTask}
onDone={() => setShowSettings(false)}
/>
) : (
<ChatSidebar />
)}
</>
)
} }
export default App export default App

View File

@@ -1,12 +1,69 @@
import React from "react" import React, { useEffect, useState } from "react"
import { VSCodeTextField, VSCodeDivider, VSCodeLink, VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { VSCodeTextField, VSCodeDivider, VSCodeLink, VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "../utilities/vscode"
const SettingsView = () => { type SettingsViewProps = {
const handleDoneClick = () => { apiKey: string
// Add your logic here for what should happen when the Done button is clicked setApiKey: React.Dispatch<React.SetStateAction<string>>
console.log("Done button clicked") maxRequestsPerTask: string
setMaxRequestsPerTask: React.Dispatch<React.SetStateAction<string>>
onDone: () => void // Define the type of the onDone prop
}
const SettingsView = ({ apiKey, setApiKey, maxRequestsPerTask, setMaxRequestsPerTask, onDone }: SettingsViewProps) => {
const [apiKeyErrorMessage, setApiKeyErrorMessage] = useState<string | undefined>(undefined)
const [maxRequestsErrorMessage, setMaxRequestsErrorMessage] = useState<string | undefined>(undefined)
const disableDoneButton = apiKeyErrorMessage != null || maxRequestsErrorMessage != null
const handleApiKeyChange = (event: any) => {
const input = event.target.value
setApiKey(input)
validateApiKey(input)
} }
const validateApiKey = (value: string) => {
if (value.trim() === "") {
setApiKeyErrorMessage("API Key cannot be empty")
} else {
setApiKeyErrorMessage(undefined)
}
}
const handleMaxRequestsChange = (event: any) => {
const input = event.target.value
setMaxRequestsPerTask(input)
validateMaxRequests(input)
}
const validateMaxRequests = (value: string | undefined) => {
if (value?.trim()) {
const num = Number(value)
if (isNaN(num)) {
setMaxRequestsErrorMessage("Maximum requests must be a number")
} else if (num < 3 || num > 100) {
setMaxRequestsErrorMessage("Maximum requests must be between 3 and 100")
} else {
setMaxRequestsErrorMessage(undefined)
}
} else {
setMaxRequestsErrorMessage(undefined)
}
}
const handleSubmit = () => {
vscode.postMessage({ type: "apiKey", text: apiKey })
vscode.postMessage({ type: "maxRequestsPerTask", text: maxRequestsPerTask })
onDone()
}
// validate as soon as the component is mounted
useEffect(() => {
validateApiKey(apiKey)
validateMaxRequests(maxRequestsPerTask)
}, [])
return ( return (
<div style={{ margin: "0 auto", paddingTop: "10px" }}> <div style={{ margin: "0 auto", paddingTop: "10px" }}>
<div <div
@@ -14,16 +71,32 @@ const SettingsView = () => {
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
marginBottom: "20px", marginBottom: "17px",
}}> }}>
<h2 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Settings</h2> <h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Settings</h3>
<VSCodeButton onClick={handleDoneClick}>Done</VSCodeButton> <VSCodeButton onClick={handleSubmit} disabled={disableDoneButton}>
Done
</VSCodeButton>
</div> </div>
<div style={{ marginBottom: "20px" }}> <div style={{ marginBottom: "20px" }}>
<VSCodeTextField style={{ width: "100%" }} placeholder="Enter your Anthropic API Key"> <VSCodeTextField
value={apiKey}
style={{ width: "100%" }}
placeholder="Enter your Anthropic API Key"
onInput={handleApiKeyChange}>
Anthropic API Key Anthropic API Key
</VSCodeTextField> </VSCodeTextField>
{apiKeyErrorMessage && (
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-errorForeground)",
}}>
{apiKeyErrorMessage}
</p>
)}
<p <p
style={{ style={{
fontSize: "12px", fontSize: "12px",
@@ -38,9 +111,23 @@ const SettingsView = () => {
</div> </div>
<div style={{ marginBottom: "20px" }}> <div style={{ marginBottom: "20px" }}>
<VSCodeTextField style={{ width: "100%" }} placeholder="Enter maximum number of requests"> <VSCodeTextField
value={maxRequestsPerTask}
style={{ width: "100%" }}
placeholder="20"
onInput={handleMaxRequestsChange}>
Maximum # Requests Per Task Maximum # Requests Per Task
</VSCodeTextField> </VSCodeTextField>
{maxRequestsErrorMessage && (
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-errorForeground)",
}}>
{maxRequestsErrorMessage}
</p>
)}
<p <p
style={{ style={{
fontSize: "12px", fontSize: "12px",
@@ -65,7 +152,7 @@ const SettingsView = () => {
}}> }}>
<p>Made possible by the latest breakthroughs in Claude 3.5 Sonnet's agentic coding capabilities.</p> <p>Made possible by the latest breakthroughs in Claude 3.5 Sonnet's agentic coding capabilities.</p>
<p> <p>
This project was submitted to Anthropic's<br/>"Build with Claude June 2024 contest" This project was made for Anthropic's "Build with Claude June 2024 contest"
<VSCodeLink href="https://github.com/saoudrizwan/claude-dev"> <VSCodeLink href="https://github.com/saoudrizwan/claude-dev">
https://github.com/saoudrizwan/claude-dev https://github.com/saoudrizwan/claude-dev
</VSCodeLink> </VSCodeLink>

View File

@@ -0,0 +1,91 @@
import React, { useState, useEffect } from "react"
import { VSCodeButton, VSCodeTextField, VSCodeLink, VSCodeDivider } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "../utilities/vscode"
interface WelcomeViewProps {
apiKey: string
setApiKey: React.Dispatch<React.SetStateAction<string>>
}
const WelcomeView: React.FC<WelcomeViewProps> = ({ apiKey, setApiKey }) => {
const [apiKeyErrorMessage, setApiKeyErrorMessage] = useState<string | undefined>(undefined)
const disableLetsGoButton = apiKeyErrorMessage != null
const handleApiKeyChange = (event: any) => {
const input = event.target.value
setApiKey(input)
validateApiKey(input)
}
const validateApiKey = (value: string) => {
if (value.trim() === "") {
setApiKeyErrorMessage("API Key cannot be empty")
} else {
setApiKeyErrorMessage(undefined)
}
}
const handleSubmit = () => {
vscode.postMessage({ type: "apiKey", text: apiKey })
}
useEffect(() => {
validateApiKey(apiKey)
}, [])
return (
<div style={{ maxWidth: "600px", margin: "0 auto", padding: "20px" }}>
<h1 style={{ color: "var(--vscode-foreground)" }}>Hi, I'm Claude Dev</h1>
<p>
I can do all kinds of tasks thanks to the latest breakthroughs in Claude Sonnet 3.5's agentic coding
capabilities. I am prompted to think through tasks step-by-step and have access to tools that let me get
information about your project, read & write code, and execute terminal commands (with your permission,
of course).
</p>
<h3>Here are some cool things I can do:</h3>
<ul>
<li>Create new projects from scratch based on your requirements</li>
<li>Debug and fix code issues in your existing projects</li>
<li>Refactor and optimize your codebase</li>
<li>Analyze your system's performance and suggest improvements</li>
<li>Generate documentation for your code</li>
<li>Set up and configure development environments</li>
<li>Perform code reviews and suggest best practices</li>
</ul>
<h3>To get started, this extension needs an Anthropic API key:</h3>
<ol>
<li>
Go to{" "}
<VSCodeLink href="https://console.anthropic.com/" style={{ display: "inline" }}>
https://console.anthropic.com/
</VSCodeLink>
</li>
<li>You may need to buy some credits (although Anthropic is offering $5 free credit for new users)</li>
<li>Click 'Get API Keys' and create a new key for me (you can delete it any time)</li>
</ol>
<VSCodeDivider />
<div style={{ marginTop: "20px", display: "flex", alignItems: "center" }}>
<VSCodeTextField
style={{ flexGrow: 1, marginRight: "10px" }}
placeholder="Enter your Anthropic API Key"
value={apiKey}
onInput={handleApiKeyChange}
/>
<VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton}>
Let's go!
</VSCodeButton>
</div>
<p style={{ fontSize: "12px", marginTop: "10px", color: "var(--vscode-descriptionForeground)" }}>
Your API key is stored securely on your computer and used only for interacting with the Anthropic API.
</p>
</div>
)
}
export default WelcomeView

View File

@@ -14,4 +14,8 @@ body {
} }
textarea:focus { textarea:focus {
outline: 1.5px solid var(--vscode-focusBorder, #007fd4); outline: 1.5px solid var(--vscode-focusBorder, #007fd4);
} }
vscode-button::part(control):focus {
outline: none;
}