diff --git a/package.json b/package.json index 25ffe6ce..c866d1ea 100644 --- a/package.json +++ b/package.json @@ -192,6 +192,13 @@ "title": "Coder Tasks", "icon": "media/tasks-logo.svg" } + ], + "panel": [ + { + "id": "coderChat", + "title": "Coder Chat (Experimental)", + "icon": "media/shorthand-logo.svg" + } ] }, "views": { @@ -224,6 +231,14 @@ "icon": "media/tasks-logo.svg", "when": "coder.authenticated" } + ], + "coderChat": [ + { + "type": "webview", + "id": "coder.chatPanel", + "name": "Coder Chat (Experimental)", + "icon": "media/shorthand-logo.svg" + } ] }, "viewsWelcome": [ diff --git a/src/core/mementoManager.ts b/src/core/mementoManager.ts index 7288433e..ca6b1860 100644 --- a/src/core/mementoManager.ts +++ b/src/core/mementoManager.ts @@ -57,4 +57,28 @@ export class MementoManager { } return isFirst === true; } + + /** + * Store a chat ID to open after a window reload. + * Used by the /open deep link handler: it must call + * commands.open() which triggers a remote-authority + * reload, wiping in-memory state. The chat ID is + * persisted here so the extension can pick it up on + * the other side of the reload. + */ + public async setPendingChatId(chatId: string): Promise { + await this.memento.update("pendingChatId", chatId); + } + + /** + * Read and clear the pending chat ID. Returns + * undefined if none was stored. + */ + public async getAndClearPendingChatId(): Promise { + const chatId = this.memento.get("pendingChatId"); + if (chatId !== undefined) { + await this.memento.update("pendingChatId", undefined); + } + return chatId; + } } diff --git a/src/extension.ts b/src/extension.ts index 4ef8a2b7..df2ecd6a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,7 @@ import { Remote } from "./remote/remote"; import { getRemoteSshExtension } from "./remote/sshExtension"; import { registerUriHandler } from "./uri/uriHandler"; import { initVscodeProposed } from "./vscodeProposed"; +import { ChatPanelProvider } from "./webviews/chat/chatPanelProvider"; import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider"; import { WorkspaceProvider, @@ -222,6 +223,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ), ); + // Register Chat embed panel with dependencies + const chatPanelProvider = new ChatPanelProvider(client, output); + ctx.subscriptions.push( + chatPanelProvider, + vscode.window.registerWebviewViewProvider( + ChatPanelProvider.viewType, + chatPanelProvider, + { webviewOptions: { retainContextWhenHidden: true } }, + ), + ); + ctx.subscriptions.push( registerUriHandler(serviceContainer, deploymentManager, commands), vscode.commands.registerCommand( @@ -315,6 +327,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { url: details.url, token: details.token, }); + + // If a deep link stored a chat agent ID before the + // remote-authority reload, open it now that the + // deployment is configured. + const pendingChatId = await mementoManager.getAndClearPendingChatId(); + if (pendingChatId) { + chatPanelProvider.openChat(pendingChatId); + } } } catch (ex) { if (ex instanceof CertificateError) { diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index d3017607..cab5c64c 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -89,6 +89,15 @@ async function handleOpen(ctx: UriRouteContext): Promise { params.has("openRecent") && (!params.get("openRecent") || params.get("openRecent") === "true"); + // Persist the chat ID before commands.open() triggers + // a remote-authority reload that wipes in-memory state. + // The extension picks this up after the reload in activate(). + const chatId = params.get("chatId"); + if (chatId) { + const mementoManager = serviceContainer.getMementoManager(); + await mementoManager.setPendingChatId(chatId); + } + await setupDeployment(params, serviceContainer, deploymentManager); await commands.open( diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts new file mode 100644 index 00000000..4ac6e360 --- /dev/null +++ b/src/webviews/chat/chatPanelProvider.ts @@ -0,0 +1,198 @@ +import { randomBytes } from "node:crypto"; + +import { type CoderApi } from "../../api/coderApi"; +import { type Logger } from "../../logging/logger"; + +import type * as vscode from "vscode"; + +/** + * Provides a webview that embeds the Coder agent chat UI. + * Authentication flows through postMessage: + * + * 1. The iframe loads /agents/{id}/embed on the Coder server. + * 2. The embed page detects the user is signed out and sends + * { type: "coder:vscode-ready" } to window.parent. + * 3. Our webview relays this to the extension host. + * 4. The extension host replies with the session token. + * 5. The webview forwards { type: "coder:vscode-auth-bootstrap" } + * with the token back into the iframe. + * 6. The embed page calls API.setSessionToken(token), re-fetches + * the authenticated user, and renders the chat UI. + */ +export class ChatPanelProvider + implements vscode.WebviewViewProvider, vscode.Disposable +{ + public static readonly viewType = "coder.chatPanel"; + + private view?: vscode.WebviewView; + private disposables: vscode.Disposable[] = []; + private chatId: string | undefined; + + constructor( + private readonly client: CoderApi, + private readonly logger: Logger, + ) {} + + /** + * Opens the chat panel for the given chat ID. + * Called after a deep link reload via the persisted + * pendingChatId, or directly for testing. + */ + public openChat(chatId: string): void { + this.chatId = chatId; + this.refresh(); + this.view?.show(true); + } + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ): void { + this.view = webviewView; + webviewView.webview.options = { enableScripts: true }; + this.disposables.push( + webviewView.webview.onDidReceiveMessage((msg: unknown) => { + this.handleMessage(msg); + }), + ); + this.renderView(); + webviewView.onDidDispose(() => this.dispose()); + } + + public refresh(): void { + if (!this.view) { + return; + } + this.renderView(); + } + + private renderView(): void { + if (!this.view) { + throw new Error("renderView called before resolveWebviewView"); + } + const webview = this.view.webview; + + if (!this.chatId) { + webview.html = this.getNoAgentHtml(); + return; + } + + const coderUrl = this.client.getHost(); + if (!coderUrl) { + webview.html = this.getNoAgentHtml(); + return; + } + + const embedUrl = `${coderUrl}/agents/${this.chatId}/embed`; + webview.html = this.getIframeHtml(embedUrl, coderUrl); + } + + private handleMessage(message: unknown): void { + if (typeof message !== "object" || message === null) { + return; + } + const msg = message as { type?: string }; + if (msg.type === "coder:vscode-ready") { + const token = this.client.getSessionToken(); + if (!token) { + this.logger.warn( + "Chat iframe requested auth but no session token available", + ); + return; + } + this.logger.info("Chat: forwarding token to iframe"); + this.view?.webview.postMessage({ + type: "coder:auth-bootstrap-token", + token, + }); + } + } + + private getIframeHtml(embedUrl: string, allowedOrigin: string): string { + const nonce = randomBytes(16).toString("base64"); + + return /* html */ ` + + + + + + Coder Chat + + + +
Loading chat…
+ + + +`; + } + + private getNoAgentHtml(): string { + return /* html */ ` + + +

No active chat session. Open a chat from the Agents tab on your Coder deployment.

`; + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + } +}