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",
"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)
const provider = new SidebarProvider(context.extensionUri)
const provider = new SidebarProvider(context)
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
*/
type ExtensionSecretKey = "apiKey"
type ExtensionGlobalStateKey = "didOpenOnce" | "maxRequestsPerTask"
export class SidebarProvider implements vscode.WebviewViewProvider {
public static readonly viewType = "claude-dev.SidebarProvider"
private _view?: vscode.WebviewView
constructor(private readonly _extensionUri: vscode.Uri) {}
constructor(private readonly context: vscode.ExtensionContext) {}
resolveWebviewView(
webviewView: vscode.WebviewView,
@@ -29,7 +31,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
webviewView.webview.options = {
// Allow scripts in the webview
enableScripts: true,
localResourceRoots: [this._extensionUri],
localResourceRoots: [this.context.extensionUri],
}
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.
// 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
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
// 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
// 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"))
@@ -78,7 +92,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
// const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.css"))
// 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
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.
@@ -119,20 +133,73 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
* @param context A reference to the extension context
*/
private _setWebviewMessageListener(webview: vscode.Webview) {
webview.onDidReceiveMessage((message: WebviewMessage) => {
webview.onDidReceiveMessage(async (message: WebviewMessage) => {
switch (message.type) {
case "webviewDidLaunch":
await this.updateGlobalState("didOpenOnce", true)
await this.postWebviewState()
break
case "text":
// Code that should run in response to the hello message command
vscode.window.showInformationMessage(message.text!)
// Send a message to our webview.
// You can send any JSON serializable data.
// Could also do this in extension .ts
// You can send any JSON serializable data.
// Could also do this in extension .ts
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
// 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
export interface ExtensionMessage {
type: "text" | "action"
type: "text" | "action" | "webviewState"
text?: string
action?: "plusButtonTapped" | "settingsButtonTapped"
webviewState?: { didOpenOnce: boolean, apiKey?: string, maxRequestsPerTask?: number }
}

View File

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

View File

@@ -4,27 +4,70 @@ import "./App.css"
import ChatSidebar from "./components/ChatSidebar"
import SettingsView from "./components/SettingsView"
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 [showSettings, setShowSettings] = useState(false)
const [showWelcome, setShowWelcome] = useState<boolean>(false)
const [apiKey, setApiKey] = useState<string>("")
const [maxRequestsPerTask, setMaxRequestsPerTask] = useState<string>("")
useEffect(() => {
vscode.postMessage({ type: "webviewDidLaunch" })
window.addEventListener("message", (e: MessageEvent) => {
const message: ExtensionMessage = e.data
if (message.type === "action") {
switch (message.action!) {
case "settingsButtonTapped":
setShowSettings(true)
break
case "plusButtonTapped":
setShowSettings(false)
break
}
// switch message.type
switch (message.type) {
case "webviewState":
const shouldShowWelcome = !message.webviewState!.didOpenOnce || !message.webviewState!.apiKey
setShowWelcome(shouldShowWelcome)
setApiKey(message.webviewState!.apiKey || "")
setMaxRequestsPerTask(
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

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 { vscode } from "../utilities/vscode"
const SettingsView = () => {
const handleDoneClick = () => {
// Add your logic here for what should happen when the Done button is clicked
console.log("Done button clicked")
type SettingsViewProps = {
apiKey: string
setApiKey: React.Dispatch<React.SetStateAction<string>>
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 (
<div style={{ margin: "0 auto", paddingTop: "10px" }}>
<div
@@ -14,16 +71,32 @@ const SettingsView = () => {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
marginBottom: "17px",
}}>
<h2 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Settings</h2>
<VSCodeButton onClick={handleDoneClick}>Done</VSCodeButton>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Settings</h3>
<VSCodeButton onClick={handleSubmit} disabled={disableDoneButton}>
Done
</VSCodeButton>
</div>
<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
</VSCodeTextField>
{apiKeyErrorMessage && (
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-errorForeground)",
}}>
{apiKeyErrorMessage}
</p>
)}
<p
style={{
fontSize: "12px",
@@ -38,9 +111,23 @@ const SettingsView = () => {
</div>
<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
</VSCodeTextField>
{maxRequestsErrorMessage && (
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-errorForeground)",
}}>
{maxRequestsErrorMessage}
</p>
)}
<p
style={{
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>
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">
https://github.com/saoudrizwan/claude-dev
</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 {
outline: 1.5px solid var(--vscode-focusBorder, #007fd4);
}
}
vscode-button::part(control):focus {
outline: none;
}