From 5dc2674c27462db475e410fcbaa12c297e16ab3c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 16 Mar 2026 17:30:44 +0000 Subject: [PATCH 01/16] feat: add embedded chat panel via deep link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Coder Chat sidebar that opens via deep link: vscode://coder.coder-remote/openChat?url=...&token=...&agentId=... The chat panel embeds /agents/:agentId/embed in an iframe through a local reverse proxy (needed to work around VS Code webview sandbox restrictions). Auth is handled via postMessage — the iframe signals readiness, the extension sends the session token, and the iframe sets it as an axios header for all API requests. - chatPanelProvider.ts: WebviewViewProvider with EmbedProxy and postMessage relay for auth bootstrap - uriHandler.ts: /openChat route that reads agentId and opens the panel - extension.ts: registers the provider and wires it into the URI handler - package.json: coderChat view container (panel area, no activity bar icon) with coder.chatPanel webview view --- package.json | 123 +++++---- src/extension.ts | 26 +- src/uri/uriHandler.ts | 18 +- src/webviews/chat/chatPanelProvider.ts | 352 +++++++++++++++++++++++++ 4 files changed, 462 insertions(+), 57 deletions(-) create mode 100644 src/webviews/chat/chatPanelProvider.ts diff --git a/package.json b/package.json index 25ffe6ce..a7377724 100644 --- a/package.json +++ b/package.json @@ -180,64 +180,77 @@ } } }, - "viewsContainers": { - "activitybar": [ - { - "id": "coder", - "title": "Coder Remote", - "icon": "media/shorthand-logo.svg" - }, - { - "id": "coderTasks", - "title": "Coder Tasks", - "icon": "media/tasks-logo.svg" - } - ] - }, - "views": { - "coder": [ - { - "id": "myWorkspaces", - "name": "My Workspaces", - "visibility": "visible", - "icon": "media/logo-white.svg" - }, - { - "id": "allWorkspaces", - "name": "All Workspaces", - "visibility": "visible", - "icon": "media/logo-white.svg", - "when": "coder.authenticated && coder.isOwner" - } - ], - "coderTasks": [ + "viewsContainers": { + "activitybar": [ + { + "id": "coder", + "title": "Coder Remote", + "icon": "media/shorthand-logo.svg" + }, + { + "id": "coderTasks", + "title": "Coder Tasks", + "icon": "media/tasks-logo.svg" + } + ], + "panel": [ + { + "id": "coderChat", + "title": "Coder Chat", + "icon": "media/shorthand-logo.svg" + } + ] + }, + "views": { + "coder": [ + { + "id": "myWorkspaces", + "name": "My Workspaces", + "visibility": "visible", + "icon": "media/logo-white.svg" + }, + { + "id": "allWorkspaces", + "name": "All Workspaces", + "visibility": "visible", + "icon": "media/logo-white.svg", + "when": "coder.authenticated && coder.isOwner" + } + ], + "coderTasks": [ + { + "id": "coder.tasksLogin", + "name": "Coder Tasks", + "icon": "media/tasks-logo.svg", + "when": "!coder.authenticated" + }, + { + "type": "webview", + "id": "coder.tasksPanel", + "name": "Coder Tasks", + "icon": "media/tasks-logo.svg", + "when": "coder.authenticated" + } + ], + "coderChat": [ + { + "type": "webview", + "id": "coder.chatPanel", + "name": "Coder Chat" + } + ] + }, + "viewsWelcome": [ { - "id": "coder.tasksLogin", - "name": "Coder Tasks", - "icon": "media/tasks-logo.svg", - "when": "!coder.authenticated" + "view": "myWorkspaces", + "contents": "Coder is a platform that provisions remote development environments. \n[Login](command:coder.login)", + "when": "!coder.authenticated && coder.loaded" }, { - "type": "webview", - "id": "coder.tasksPanel", - "name": "Coder Tasks", - "icon": "media/tasks-logo.svg", - "when": "coder.authenticated" - } - ] - }, - "viewsWelcome": [ - { - "view": "myWorkspaces", - "contents": "Coder is a platform that provisions remote development environments. \n[Login](command:coder.login)", - "when": "!coder.authenticated && coder.loaded" - }, - { - "view": "coder.tasksLogin", - "contents": "Sign in to view and manage Coder tasks.\n[Login](command:coder.login)", - "when": "!coder.authenticated && coder.loaded" - } - ], + "view": "coder.tasksLogin", + "contents": "Sign in to view and manage Coder tasks.\n[Login](command:coder.login)", + "when": "!coder.authenticated && coder.loaded" + } ], "commands": [ { "command": "coder.login", diff --git a/src/extension.ts b/src/extension.ts index 4ef8a2b7..fc0e035e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,6 +20,7 @@ import { getRemoteSshExtension } from "./remote/sshExtension"; import { registerUriHandler } from "./uri/uriHandler"; import { initVscodeProposed } from "./vscodeProposed"; import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider"; +import { ChatPanelProvider } from "./webviews/chat/chatPanelProvider"; import { WorkspaceProvider, WorkspaceQuery, @@ -222,8 +223,21 @@ 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( "coder.login", commands.login.bind(commands), @@ -291,6 +305,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const remote = new Remote(serviceContainer, commands, ctx); + // Register the URI handler so deep links (e.g. /openChat) work. + ctx.subscriptions.push( + registerUriHandler( + serviceContainer, + deploymentManager, + commands, + chatPanelProvider, + ), + ); + // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists // in package.json we're able to perform actions before the authority is // resolved by the remote SSH extension. diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index d3017607..fef12157 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -8,12 +8,14 @@ import { CALLBACK_PATH } from "../oauth/utils"; import { maybeAskUrl } from "../promptUtils"; import { toSafeHost } from "../util"; import { vscodeProposed } from "../vscodeProposed"; +import { type ChatPanelProvider } from "../webviews/chat/chatPanelProvider"; interface UriRouteContext { params: URLSearchParams; serviceContainer: ServiceContainer; deploymentManager: DeploymentManager; commands: Commands; + chatPanelProvider: ChatPanelProvider; } type UriRouteHandler = (ctx: UriRouteContext) => Promise; @@ -21,6 +23,7 @@ type UriRouteHandler = (ctx: UriRouteContext) => Promise; const routes: Readonly> = { "/open": handleOpen, "/openDevContainer": handleOpenDevContainer, + "/openChat": handleOpenChat, [CALLBACK_PATH]: handleOAuthCallback, }; @@ -31,13 +34,14 @@ export function registerUriHandler( serviceContainer: ServiceContainer, deploymentManager: DeploymentManager, commands: Commands, + chatPanelProvider: ChatPanelProvider, ): vscode.Disposable { const output = serviceContainer.getLogger(); return vscode.window.registerUriHandler({ handleUri: async (uri) => { try { - await routeUri(uri, serviceContainer, deploymentManager, commands); + await routeUri(uri, serviceContainer, deploymentManager, commands, chatPanelProvider); } catch (error) { const message = errToStr(error, "No error message was provided"); output.warn(`Failed to handle URI ${uri.toString()}: ${message}`); @@ -56,6 +60,7 @@ async function routeUri( serviceContainer: ServiceContainer, deploymentManager: DeploymentManager, commands: Commands, + chatPanelProvider: ChatPanelProvider, ): Promise { const handler = routes[uri.path]; if (!handler) { @@ -67,6 +72,7 @@ async function routeUri( serviceContainer, deploymentManager, commands, + chatPanelProvider, }); } @@ -180,6 +186,16 @@ async function setupDeployment( }); } +async function handleOpenChat(ctx: UriRouteContext): Promise { + const { params, serviceContainer, deploymentManager, chatPanelProvider } = ctx; + + const agentId = getRequiredParam(params, "agentId"); + + await setupDeployment(params, serviceContainer, deploymentManager); + + chatPanelProvider.openChat(agentId); +} + async function handleOAuthCallback(ctx: UriRouteContext): Promise { const { params, serviceContainer } = ctx; const logger = serviceContainer.getLogger(); diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts new file mode 100644 index 00000000..7601c27e --- /dev/null +++ b/src/webviews/chat/chatPanelProvider.ts @@ -0,0 +1,352 @@ +import * as http from "node:http"; +import { randomBytes } from "node:crypto"; +import { URL } from "node:url"; +import * as vscode from "vscode"; + +import { type CoderApi } from "../../api/coderApi"; +import { type Logger } from "../../logging/logger"; + +/** + * A local reverse proxy that forwards requests to the Coder server. + * This exists solely to work around VS Code's webview sandbox which + * blocks script execution in nested cross-origin iframes. By serving + * through a local proxy the iframe gets its own browsing context and + * scripts execute normally. + * + * The proxy does NOT inject auth headers — authentication is handled + * entirely via the postMessage bootstrap flow. + */ +class EmbedProxy implements vscode.Disposable { + private server?: http.Server; + private _port = 0; + + constructor( + private readonly coderUrl: string, + private readonly logger: Logger, + ) {} + + get port(): number { + return this._port; + } + + async start(): Promise { + const target = new URL(this.coderUrl); + + this.server = http.createServer((req, res) => { + const options: http.RequestOptions = { + hostname: target.hostname, + port: target.port || 80, + path: req.url, + method: req.method, + headers: { + ...req.headers, + host: target.host, + }, + }; + + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + }); + + proxyReq.on("error", (err) => { + this.logger.warn("Embed proxy request error", err); + res.writeHead(502); + res.end("Bad Gateway"); + }); + + req.pipe(proxyReq, { end: true }); + }); + + return new Promise((resolve, reject) => { + this.server!.listen(0, "127.0.0.1", () => { + const addr = this.server!.address(); + if (typeof addr === "object" && addr !== null) { + this._port = addr.port; + this.logger.info( + `Embed proxy listening on 127.0.0.1:${this._port}`, + ); + resolve(this._port); + } else { + reject(new Error("Failed to bind embed proxy")); + } + }); + this.server!.on("error", reject); + }); + } + + dispose(): void { + this.server?.close(); + } +} + +/** + * Provides a webview that embeds the Coder agent chat UI via a local + * proxy. Authentication flows through postMessage: + * + * 1. The iframe loads /agents/{id}/embed through the proxy. + * 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 proxy?: EmbedProxy; + private agentId: string | undefined; + + constructor( + private readonly client: CoderApi, + private readonly logger: Logger, + ) {} + + /** + * Called by the `/openChat` URI handler. + */ + public openChat(agentId: string): void { + this.agentId = agentId; + this.refresh(); + vscode.commands + .executeCommand("workbench.action.focusAuxiliaryBar") + .then(() => + vscode.commands.executeCommand("coder.chatPanel.focus"), + ); + } + + async resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ): Promise { + this.view = webviewView; + + if (!this.agentId) { + webviewView.webview.html = this.getNoAgentHtml(); + return; + } + + const coderUrl = this.client.getHost(); + if (!coderUrl) { + webviewView.webview.html = this.getNoAgentHtml(); + return; + } + + webviewView.webview.options = { enableScripts: true }; + + this.disposeInternals(); + + this.disposables.push( + webviewView.webview.onDidReceiveMessage((msg: unknown) => { + this.handleMessage(msg); + }), + ); + + this.proxy = new EmbedProxy(coderUrl, this.logger); + this.disposables.push(this.proxy); + + try { + const port = await this.proxy.start(); + const proxyOrigin = `http://127.0.0.1:${port}`; + const embedUrl = `${proxyOrigin}/agents/${this.agentId}/embed`; + webviewView.webview.html = this.getIframeHtml( + embedUrl, + proxyOrigin, + ); + } catch (err) { + this.logger.error("Failed to start embed proxy", err); + webviewView.webview.html = this.getErrorHtml( + "Failed to start embed proxy.", + ); + } + + webviewView.onDidDispose(() => this.disposeInternals()); + } + + public refresh(): void { + if (!this.view) { + return; + } + + if (!this.agentId) { + this.view.webview.html = this.getNoAgentHtml(); + return; + } + + const coderUrl = this.client.getHost(); + if (!coderUrl) { + this.view.webview.html = this.getNoAgentHtml(); + return; + } + + this.disposeInternals(); + + this.proxy = new EmbedProxy(coderUrl, this.logger); + this.disposables.push(this.proxy); + + this.disposables.push( + this.view.webview.onDidReceiveMessage((msg: unknown) => { + this.handleMessage(msg); + }), + ); + + this.proxy + .start() + .then((port) => { + const proxyOrigin = `http://127.0.0.1:${port}`; + const embedUrl = `${proxyOrigin}/agents/${this.agentId}/embed`; + if (this.view) { + this.view.webview.options = { enableScripts: true }; + this.view.webview.html = this.getIframeHtml( + embedUrl, + proxyOrigin, + ); + } + }) + .catch((err) => { + this.logger.error("Failed to restart embed proxy", err); + if (this.view) { + this.view.webview.html = this.getErrorHtml( + "Failed to start embed proxy.", + ); + } + }); + } + + 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, proxyOrigin: 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.

`; + } + + private getErrorHtml(message: string): string { + return /* html */ ` + + +

${message}

`; + } + + private disposeInternals(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + } + + dispose(): void { + this.disposeInternals(); + } +} From 157ff8e9155d5d52cd041fc734dddd2825228d8c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 16 Mar 2026 22:15:39 +0000 Subject: [PATCH 02/16] =?UTF-8?q?refactor:=20remove=20EmbedProxy=20?= =?UTF-8?q?=E2=80=94=20iframe=20loads=20directly=20from=20Coder=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local reverse proxy was added to work around VS Code's webview sandbox blocking script execution in nested cross-origin iframes. Testing confirms the sandbox restriction does not apply when the iframe loads directly from the Coder server URL, so the proxy is unnecessary complexity. Auth continues to flow via postMessage+setSessionToken (no cookies, no CSRF, no proxy header injection). --- src/webviews/chat/chatPanelProvider.ts | 133 ++----------------------- 1 file changed, 10 insertions(+), 123 deletions(-) diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index 7601c27e..6cbd61fd 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -1,90 +1,14 @@ -import * as http from "node:http"; import { randomBytes } from "node:crypto"; -import { URL } from "node:url"; import * as vscode from "vscode"; import { type CoderApi } from "../../api/coderApi"; import { type Logger } from "../../logging/logger"; /** - * A local reverse proxy that forwards requests to the Coder server. - * This exists solely to work around VS Code's webview sandbox which - * blocks script execution in nested cross-origin iframes. By serving - * through a local proxy the iframe gets its own browsing context and - * scripts execute normally. + * Provides a webview that embeds the Coder agent chat UI. + * Authentication flows through postMessage: * - * The proxy does NOT inject auth headers — authentication is handled - * entirely via the postMessage bootstrap flow. - */ -class EmbedProxy implements vscode.Disposable { - private server?: http.Server; - private _port = 0; - - constructor( - private readonly coderUrl: string, - private readonly logger: Logger, - ) {} - - get port(): number { - return this._port; - } - - async start(): Promise { - const target = new URL(this.coderUrl); - - this.server = http.createServer((req, res) => { - const options: http.RequestOptions = { - hostname: target.hostname, - port: target.port || 80, - path: req.url, - method: req.method, - headers: { - ...req.headers, - host: target.host, - }, - }; - - const proxyReq = http.request(options, (proxyRes) => { - res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); - proxyRes.pipe(res, { end: true }); - }); - - proxyReq.on("error", (err) => { - this.logger.warn("Embed proxy request error", err); - res.writeHead(502); - res.end("Bad Gateway"); - }); - - req.pipe(proxyReq, { end: true }); - }); - - return new Promise((resolve, reject) => { - this.server!.listen(0, "127.0.0.1", () => { - const addr = this.server!.address(); - if (typeof addr === "object" && addr !== null) { - this._port = addr.port; - this.logger.info( - `Embed proxy listening on 127.0.0.1:${this._port}`, - ); - resolve(this._port); - } else { - reject(new Error("Failed to bind embed proxy")); - } - }); - this.server!.on("error", reject); - }); - } - - dispose(): void { - this.server?.close(); - } -} - -/** - * Provides a webview that embeds the Coder agent chat UI via a local - * proxy. Authentication flows through postMessage: - * - * 1. The iframe loads /agents/{id}/embed through the proxy. + * 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. @@ -101,7 +25,6 @@ export class ChatPanelProvider private view?: vscode.WebviewView; private disposables: vscode.Disposable[] = []; - private proxy?: EmbedProxy; private agentId: string | undefined; constructor( @@ -150,23 +73,8 @@ export class ChatPanelProvider }), ); - this.proxy = new EmbedProxy(coderUrl, this.logger); - this.disposables.push(this.proxy); - - try { - const port = await this.proxy.start(); - const proxyOrigin = `http://127.0.0.1:${port}`; - const embedUrl = `${proxyOrigin}/agents/${this.agentId}/embed`; - webviewView.webview.html = this.getIframeHtml( - embedUrl, - proxyOrigin, - ); - } catch (err) { - this.logger.error("Failed to start embed proxy", err); - webviewView.webview.html = this.getErrorHtml( - "Failed to start embed proxy.", - ); - } + const embedUrl = `${coderUrl}/agents/${this.agentId}/embed`; + webviewView.webview.html = this.getIframeHtml(embedUrl, coderUrl); webviewView.onDidDispose(() => this.disposeInternals()); } @@ -189,36 +97,15 @@ export class ChatPanelProvider this.disposeInternals(); - this.proxy = new EmbedProxy(coderUrl, this.logger); - this.disposables.push(this.proxy); - this.disposables.push( this.view.webview.onDidReceiveMessage((msg: unknown) => { this.handleMessage(msg); }), ); - this.proxy - .start() - .then((port) => { - const proxyOrigin = `http://127.0.0.1:${port}`; - const embedUrl = `${proxyOrigin}/agents/${this.agentId}/embed`; - if (this.view) { - this.view.webview.options = { enableScripts: true }; - this.view.webview.html = this.getIframeHtml( - embedUrl, - proxyOrigin, - ); - } - }) - .catch((err) => { - this.logger.error("Failed to restart embed proxy", err); - if (this.view) { - this.view.webview.html = this.getErrorHtml( - "Failed to start embed proxy.", - ); - } - }); + this.view.webview.options = { enableScripts: true }; + const embedUrl = `${coderUrl}/agents/${this.agentId}/embed`; + this.view.webview.html = this.getIframeHtml(embedUrl, coderUrl); } private handleMessage(message: unknown): void { @@ -242,7 +129,7 @@ export class ChatPanelProvider } } - private getIframeHtml(embedUrl: string, proxyOrigin: string): string { + private getIframeHtml(embedUrl: string, allowedOrigin: string): string { const nonce = randomBytes(16).toString("base64"); return /* html */ ` @@ -251,7 +138,7 @@ export class ChatPanelProvider From aa5ab7fb006285ac5aeab021cb8d3440626161b8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Mar 2026 12:19:03 +0000 Subject: [PATCH 03/16] refactor: remove dead code, fix duplication and indentation - Remove unused getErrorHtml() (dead after proxy removal). - Extract renderView() to deduplicate resolveWebviewView/refresh. - Fix package.json indentation (extra nesting introduced in prior commit). --- src/webviews/chat/chatPanelProvider.ts | 51 ++++++-------------------- 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index 6cbd61fd..780f7bd8 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -51,31 +51,7 @@ export class ChatPanelProvider _token: vscode.CancellationToken, ): Promise { this.view = webviewView; - - if (!this.agentId) { - webviewView.webview.html = this.getNoAgentHtml(); - return; - } - - const coderUrl = this.client.getHost(); - if (!coderUrl) { - webviewView.webview.html = this.getNoAgentHtml(); - return; - } - - webviewView.webview.options = { enableScripts: true }; - - this.disposeInternals(); - - this.disposables.push( - webviewView.webview.onDidReceiveMessage((msg: unknown) => { - this.handleMessage(msg); - }), - ); - - const embedUrl = `${coderUrl}/agents/${this.agentId}/embed`; - webviewView.webview.html = this.getIframeHtml(embedUrl, coderUrl); - + this.renderView(); webviewView.onDidDispose(() => this.disposeInternals()); } @@ -83,29 +59,35 @@ export class ChatPanelProvider if (!this.view) { return; } + this.renderView(); + } + + private renderView(): void { + const webview = this.view!.webview; if (!this.agentId) { - this.view.webview.html = this.getNoAgentHtml(); + webview.html = this.getNoAgentHtml(); return; } const coderUrl = this.client.getHost(); if (!coderUrl) { - this.view.webview.html = this.getNoAgentHtml(); + webview.html = this.getNoAgentHtml(); return; } this.disposeInternals(); + webview.options = { enableScripts: true }; + this.disposables.push( - this.view.webview.onDidReceiveMessage((msg: unknown) => { + webview.onDidReceiveMessage((msg: unknown) => { this.handleMessage(msg); }), ); - this.view.webview.options = { enableScripts: true }; const embedUrl = `${coderUrl}/agents/${this.agentId}/embed`; - this.view.webview.html = this.getIframeHtml(embedUrl, coderUrl); + webview.html = this.getIframeHtml(embedUrl, coderUrl); } private handleMessage(message: unknown): void { @@ -217,15 +199,6 @@ text-align:center;}

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

`; } - private getErrorHtml(message: string): string { - return /* html */ ` - - -

${message}

`; - } - private disposeInternals(): void { for (const d of this.disposables) { d.dispose(); From 51316bf430c9f3d3f162710245ae1ddd07c673b3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Mar 2026 12:37:40 +0000 Subject: [PATCH 04/16] refactor: simplify chat panel rendering --- src/webviews/chat/chatPanelProvider.ts | 30 ++++++-------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index 780f7bd8..91d4b488 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -38,11 +38,7 @@ export class ChatPanelProvider public openChat(agentId: string): void { this.agentId = agentId; this.refresh(); - vscode.commands - .executeCommand("workbench.action.focusAuxiliaryBar") - .then(() => - vscode.commands.executeCommand("coder.chatPanel.focus"), - ); + void vscode.commands.executeCommand("coder.chatPanel.focus"); } async resolveWebviewView( @@ -51,6 +47,12 @@ export class ChatPanelProvider _token: vscode.CancellationToken, ): Promise { this.view = webviewView; + webviewView.webview.options = { enableScripts: true }; + this.disposables.push( + webviewView.webview.onDidReceiveMessage((msg: unknown) => { + this.handleMessage(msg); + }), + ); this.renderView(); webviewView.onDidDispose(() => this.disposeInternals()); } @@ -76,16 +78,6 @@ export class ChatPanelProvider return; } - this.disposeInternals(); - - webview.options = { enableScripts: true }; - - this.disposables.push( - webview.onDidReceiveMessage((msg: unknown) => { - this.handleMessage(msg); - }), - ); - const embedUrl = `${coderUrl}/agents/${this.agentId}/embed`; webview.html = this.getIframeHtml(embedUrl, coderUrl); } @@ -150,12 +142,7 @@ export class ChatPanelProvider const iframe = document.getElementById('chat-frame'); const status = document.getElementById('status'); - function log(msg) { console.log('[CoderChat] ' + msg); } - - log('Webview loaded, iframe src: ' + iframe.src); - iframe.addEventListener('load', () => { - log('iframe load event'); iframe.style.display = 'block'; status.style.display = 'none'; }); @@ -165,9 +152,7 @@ export class ChatPanelProvider if (!data || typeof data !== 'object') return; if (event.source === iframe.contentWindow) { - log('From iframe: ' + JSON.stringify(data.type)); if (data.type === 'coder:vscode-ready') { - log('Requesting token from extension host'); status.textContent = 'Authenticating…'; vscode.postMessage({ type: 'coder:vscode-ready' }); } @@ -175,7 +160,6 @@ export class ChatPanelProvider } if (data.type === 'coder:auth-bootstrap-token') { - log('Forwarding token to iframe'); status.textContent = 'Signing in…'; iframe.contentWindow.postMessage({ type: 'coder:vscode-auth-bootstrap', From 0cc8c020f6bae3360d47a648e461d141719828db Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Mar 2026 13:21:54 +0000 Subject: [PATCH 05/16] fix: pass chatPanelProvider mock to registerUriHandler in test --- test/unit/uri/uriHandler.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/unit/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts index b8f0c3e5..203b628e 100644 --- a/test/unit/uri/uriHandler.test.ts +++ b/test/unit/uri/uriHandler.test.ts @@ -6,6 +6,7 @@ import { SecretsManager } from "@/core/secretsManager"; import { CALLBACK_PATH } from "@/oauth/utils"; import { maybeAskUrl } from "@/promptUtils"; import { registerUriHandler } from "@/uri/uriHandler"; +import { type ChatPanelProvider } from "@/webviews/chat/chatPanelProvider"; import { createMockLogger, @@ -98,10 +99,13 @@ function createTestContext() { .mocked(vscode.window.showErrorMessage) .mockResolvedValue(undefined); + const chatPanelProvider = {} as unknown as ChatPanelProvider; + registerUriHandler( container, deploymentManager as unknown as DeploymentManager, commands as unknown as Commands, + chatPanelProvider, ); return { From 02d44b241bada1b9e93b1a8ec768321bfeb224a7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Mar 2026 13:31:52 +0000 Subject: [PATCH 06/16] fix: lint errors and formatting --- src/extension.ts | 7 ++----- src/uri/uriHandler.ts | 11 +++++++++-- src/webviews/chat/chatPanelProvider.ts | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index fc0e035e..7a219fbe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,8 +19,8 @@ import { Remote } from "./remote/remote"; import { getRemoteSshExtension } from "./remote/sshExtension"; import { registerUriHandler } from "./uri/uriHandler"; import { initVscodeProposed } from "./vscodeProposed"; -import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider"; import { ChatPanelProvider } from "./webviews/chat/chatPanelProvider"; +import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider"; import { WorkspaceProvider, WorkspaceQuery, @@ -224,10 +224,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); // Register Chat embed panel with dependencies - const chatPanelProvider = new ChatPanelProvider( - client, - output, - ); + const chatPanelProvider = new ChatPanelProvider(client, output); ctx.subscriptions.push( chatPanelProvider, vscode.window.registerWebviewViewProvider( diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index fef12157..c2ce6114 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -41,7 +41,13 @@ export function registerUriHandler( return vscode.window.registerUriHandler({ handleUri: async (uri) => { try { - await routeUri(uri, serviceContainer, deploymentManager, commands, chatPanelProvider); + await routeUri( + uri, + serviceContainer, + deploymentManager, + commands, + chatPanelProvider, + ); } catch (error) { const message = errToStr(error, "No error message was provided"); output.warn(`Failed to handle URI ${uri.toString()}: ${message}`); @@ -187,7 +193,8 @@ async function setupDeployment( } async function handleOpenChat(ctx: UriRouteContext): Promise { - const { params, serviceContainer, deploymentManager, chatPanelProvider } = ctx; + const { params, serviceContainer, deploymentManager, chatPanelProvider } = + ctx; const agentId = getRequiredParam(params, "agentId"); diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index 91d4b488..b3f299ab 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -41,11 +41,11 @@ export class ChatPanelProvider void vscode.commands.executeCommand("coder.chatPanel.focus"); } - async resolveWebviewView( + resolveWebviewView( webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, - ): Promise { + ): void { this.view = webviewView; webviewView.webview.options = { enableScripts: true }; this.disposables.push( From 1e19564533ab3d38a84328e9f86df2b214b0cd98 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Mar 2026 13:40:20 +0000 Subject: [PATCH 07/16] feat: open chat panel from /open when agentId param is present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing /open deep link now optionally accepts an agentId query parameter. When present, the chat panel opens alongside the workspace. Old extensions silently ignore unknown query params, so this is fully backwards compatible — no error dialog, no version negotiation needed. The standalone /openChat route is kept for cases where no workspace context is needed (e.g. direct links from the agents page). --- src/uri/uriHandler.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index c2ce6114..95de9e4f 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -91,7 +91,13 @@ function getRequiredParam(params: URLSearchParams, name: string): string { } async function handleOpen(ctx: UriRouteContext): Promise { - const { params, serviceContainer, deploymentManager, commands } = ctx; + const { + params, + serviceContainer, + deploymentManager, + commands, + chatPanelProvider, + } = ctx; const owner = getRequiredParam(params, "owner"); const workspace = getRequiredParam(params, "workspace"); @@ -101,6 +107,11 @@ async function handleOpen(ctx: UriRouteContext): Promise { params.has("openRecent") && (!params.get("openRecent") || params.get("openRecent") === "true"); + // Optional: if agentId is present, also open the embedded chat + // panel. Old extensions silently ignore this unknown param, + // giving backwards compatibility. + const agentId = params.get("agentId"); + await setupDeployment(params, serviceContainer, deploymentManager); await commands.open( @@ -110,6 +121,10 @@ async function handleOpen(ctx: UriRouteContext): Promise { folder ?? undefined, openRecent, ); + + if (agentId) { + chatPanelProvider.openChat(agentId); + } } async function handleOpenDevContainer(ctx: UriRouteContext): Promise { From d9c84170deab64640496009d84d0c37560ed4aab Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Mar 2026 15:39:28 +0000 Subject: [PATCH 08/16] chore: format package.json --- package.json | 137 ++++++++++++++++++++++++++------------------------- 1 file changed, 69 insertions(+), 68 deletions(-) diff --git a/package.json b/package.json index a7377724..4c528e7c 100644 --- a/package.json +++ b/package.json @@ -180,77 +180,78 @@ } } }, - "viewsContainers": { - "activitybar": [ - { - "id": "coder", - "title": "Coder Remote", - "icon": "media/shorthand-logo.svg" - }, - { - "id": "coderTasks", - "title": "Coder Tasks", - "icon": "media/tasks-logo.svg" - } - ], - "panel": [ - { - "id": "coderChat", - "title": "Coder Chat", - "icon": "media/shorthand-logo.svg" - } - ] - }, - "views": { - "coder": [ - { - "id": "myWorkspaces", - "name": "My Workspaces", - "visibility": "visible", - "icon": "media/logo-white.svg" - }, - { - "id": "allWorkspaces", - "name": "All Workspaces", - "visibility": "visible", - "icon": "media/logo-white.svg", - "when": "coder.authenticated && coder.isOwner" - } - ], - "coderTasks": [ - { - "id": "coder.tasksLogin", - "name": "Coder Tasks", - "icon": "media/tasks-logo.svg", - "when": "!coder.authenticated" - }, - { - "type": "webview", - "id": "coder.tasksPanel", - "name": "Coder Tasks", - "icon": "media/tasks-logo.svg", - "when": "coder.authenticated" - } - ], - "coderChat": [ - { - "type": "webview", - "id": "coder.chatPanel", - "name": "Coder Chat" - } - ] - }, - "viewsWelcome": [ + "viewsContainers": { + "activitybar": [ + { + "id": "coder", + "title": "Coder Remote", + "icon": "media/shorthand-logo.svg" + }, + { + "id": "coderTasks", + "title": "Coder Tasks", + "icon": "media/tasks-logo.svg" + } + ], + "panel": [ + { + "id": "coderChat", + "title": "Coder Chat", + "icon": "media/shorthand-logo.svg" + } + ] + }, + "views": { + "coder": [ { - "view": "myWorkspaces", - "contents": "Coder is a platform that provisions remote development environments. \n[Login](command:coder.login)", - "when": "!coder.authenticated && coder.loaded" + "id": "myWorkspaces", + "name": "My Workspaces", + "visibility": "visible", + "icon": "media/logo-white.svg" }, { - "view": "coder.tasksLogin", - "contents": "Sign in to view and manage Coder tasks.\n[Login](command:coder.login)", - "when": "!coder.authenticated && coder.loaded" - } ], + "id": "allWorkspaces", + "name": "All Workspaces", + "visibility": "visible", + "icon": "media/logo-white.svg", + "when": "coder.authenticated && coder.isOwner" + } + ], + "coderTasks": [ + { + "id": "coder.tasksLogin", + "name": "Coder Tasks", + "icon": "media/tasks-logo.svg", + "when": "!coder.authenticated" + }, + { + "type": "webview", + "id": "coder.tasksPanel", + "name": "Coder Tasks", + "icon": "media/tasks-logo.svg", + "when": "coder.authenticated" + } + ], + "coderChat": [ + { + "type": "webview", + "id": "coder.chatPanel", + "name": "Coder Chat" + } + ] + }, + "viewsWelcome": [ + { + "view": "myWorkspaces", + "contents": "Coder is a platform that provisions remote development environments. \n[Login](command:coder.login)", + "when": "!coder.authenticated && coder.loaded" + }, + { + "view": "coder.tasksLogin", + "contents": "Sign in to view and manage Coder tasks.\n[Login](command:coder.login)", + "when": "!coder.authenticated && coder.loaded" + } + ], "commands": [ { "command": "coder.login", From a0b8f5934f0bc30ac9cf1155b6b65b408a77d9fa Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Mar 2026 18:21:39 +0000 Subject: [PATCH 09/16] feat: persist agentId across remote-authority reload commands.open() triggers vscode.openFolder with a remote authority, which reloads the window and wipes in-memory state. To survive this, handleOpen now saves the agentId to globalState before the reload. After the reload, activate() reads and clears it once the deployment is configured, then opens the chat panel. This follows the same set-and-clear pattern used by firstConnect. --- src/core/mementoManager.ts | 24 ++++++++++++++++++++++++ src/extension.ts | 9 +++++++++ src/uri/uriHandler.ts | 22 ++++++++-------------- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/core/mementoManager.ts b/src/core/mementoManager.ts index 7288433e..11a3fcd9 100644 --- a/src/core/mementoManager.ts +++ b/src/core/mementoManager.ts @@ -57,4 +57,28 @@ export class MementoManager { } return isFirst === true; } + + /** + * Store a chat agent 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 agent ID is + * persisted here so the extension can pick it up on + * the other side of the reload. + */ + public async setPendingChatAgentId(agentId: string): Promise { + await this.memento.update("pendingChatAgentId", agentId); + } + + /** + * Read and clear the pending chat agent ID. Returns + * undefined if none was stored. + */ + public async getAndClearPendingChatAgentId(): Promise { + const agentId = this.memento.get("pendingChatAgentId"); + if (agentId !== undefined) { + await this.memento.update("pendingChatAgentId", undefined); + } + return agentId; + } } diff --git a/src/extension.ts b/src/extension.ts index 7a219fbe..33d5a7b1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -336,6 +336,15 @@ 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 pendingAgentId = + await mementoManager.getAndClearPendingChatAgentId(); + if (pendingAgentId) { + chatPanelProvider.openChat(pendingAgentId); + } } } catch (ex) { if (ex instanceof CertificateError) { diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index 95de9e4f..b88b9426 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -91,13 +91,7 @@ function getRequiredParam(params: URLSearchParams, name: string): string { } async function handleOpen(ctx: UriRouteContext): Promise { - const { - params, - serviceContainer, - deploymentManager, - commands, - chatPanelProvider, - } = ctx; + const { params, serviceContainer, deploymentManager, commands } = ctx; const owner = getRequiredParam(params, "owner"); const workspace = getRequiredParam(params, "workspace"); @@ -107,10 +101,14 @@ async function handleOpen(ctx: UriRouteContext): Promise { params.has("openRecent") && (!params.get("openRecent") || params.get("openRecent") === "true"); - // Optional: if agentId is present, also open the embedded chat - // panel. Old extensions silently ignore this unknown param, - // giving backwards compatibility. + // Persist the chat agent 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 agentId = params.get("agentId"); + if (agentId) { + const mementoManager = serviceContainer.getMementoManager(); + await mementoManager.setPendingChatAgentId(agentId); + } await setupDeployment(params, serviceContainer, deploymentManager); @@ -121,10 +119,6 @@ async function handleOpen(ctx: UriRouteContext): Promise { folder ?? undefined, openRecent, ); - - if (agentId) { - chatPanelProvider.openChat(agentId); - } } async function handleOpenDevContainer(ctx: UriRouteContext): Promise { From 8cc5b1131080a5023e363d81989c3a684c7de994 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Mar 2026 18:39:25 +0000 Subject: [PATCH 10/16] refactor: move registerUriHandler back to original location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No reason for it to be in a separate ctx.subscriptions.push block — chatPanelProvider is already created above. --- src/extension.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 33d5a7b1..492ddc9a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -235,6 +235,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); ctx.subscriptions.push( + registerUriHandler( + serviceContainer, + deploymentManager, + commands, + chatPanelProvider, + ), vscode.commands.registerCommand( "coder.login", commands.login.bind(commands), @@ -302,16 +308,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const remote = new Remote(serviceContainer, commands, ctx); - // Register the URI handler so deep links (e.g. /openChat) work. - ctx.subscriptions.push( - registerUriHandler( - serviceContainer, - deploymentManager, - commands, - chatPanelProvider, - ), - ); - // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists // in package.json we're able to perform actions before the authority is // resolved by the remote SSH extension. From 7852f5b04d573f651b59edcc4ecae3bd203fee0c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 17 Mar 2026 18:40:16 +0000 Subject: [PATCH 11/16] refactor: remove disposeInternals, use dispose directly --- src/webviews/chat/chatPanelProvider.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index b3f299ab..26b9bf59 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -54,7 +54,7 @@ export class ChatPanelProvider }), ); this.renderView(); - webviewView.onDidDispose(() => this.disposeInternals()); + webviewView.onDidDispose(() => this.dispose()); } public refresh(): void { @@ -183,14 +183,10 @@ text-align:center;}

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

`; } - private disposeInternals(): void { + dispose(): void { for (const d of this.disposables) { d.dispose(); } this.disposables = []; } - - dispose(): void { - this.disposeInternals(); - } } From 5c933a82ba47d41d522dd5041939d8ffa174b91b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 18 Mar 2026 07:29:01 +0000 Subject: [PATCH 12/16] refactor: replace non-null assertion with explicit throw in renderView --- src/webviews/chat/chatPanelProvider.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index 26b9bf59..aef9b5b7 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -65,7 +65,10 @@ export class ChatPanelProvider } private renderView(): void { - const webview = this.view!.webview; + if (!this.view) { + throw new Error("renderView called before resolveWebviewView"); + } + const webview = this.view.webview; if (!this.agentId) { webview.html = this.getNoAgentHtml(); From 444bf4d906be7c3ba73517fb0cfa1c74f8ce7d5d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 18 Mar 2026 07:34:45 +0000 Subject: [PATCH 13/16] refactor: remove /openChat route and chatPanelProvider from URI handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /open route with an optional agentId param is the only deep link path for opening the chat panel. The standalone /openChat route and the chatPanelProvider dependency in the URI handler are no longer needed — the chat panel is opened from extension.ts after the remote-authority reload picks up the persisted agentId. --- src/extension.ts | 7 +------ src/uri/uriHandler.ts | 25 +------------------------ test/unit/uri/uriHandler.test.ts | 4 ---- 3 files changed, 2 insertions(+), 34 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 492ddc9a..3f633abf 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -235,12 +235,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); ctx.subscriptions.push( - registerUriHandler( - serviceContainer, - deploymentManager, - commands, - chatPanelProvider, - ), + registerUriHandler(serviceContainer, deploymentManager, commands), vscode.commands.registerCommand( "coder.login", commands.login.bind(commands), diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index b88b9426..66933695 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -8,14 +8,12 @@ import { CALLBACK_PATH } from "../oauth/utils"; import { maybeAskUrl } from "../promptUtils"; import { toSafeHost } from "../util"; import { vscodeProposed } from "../vscodeProposed"; -import { type ChatPanelProvider } from "../webviews/chat/chatPanelProvider"; interface UriRouteContext { params: URLSearchParams; serviceContainer: ServiceContainer; deploymentManager: DeploymentManager; commands: Commands; - chatPanelProvider: ChatPanelProvider; } type UriRouteHandler = (ctx: UriRouteContext) => Promise; @@ -23,7 +21,6 @@ type UriRouteHandler = (ctx: UriRouteContext) => Promise; const routes: Readonly> = { "/open": handleOpen, "/openDevContainer": handleOpenDevContainer, - "/openChat": handleOpenChat, [CALLBACK_PATH]: handleOAuthCallback, }; @@ -34,20 +31,13 @@ export function registerUriHandler( serviceContainer: ServiceContainer, deploymentManager: DeploymentManager, commands: Commands, - chatPanelProvider: ChatPanelProvider, ): vscode.Disposable { const output = serviceContainer.getLogger(); return vscode.window.registerUriHandler({ handleUri: async (uri) => { try { - await routeUri( - uri, - serviceContainer, - deploymentManager, - commands, - chatPanelProvider, - ); + await routeUri(uri, serviceContainer, deploymentManager, commands); } catch (error) { const message = errToStr(error, "No error message was provided"); output.warn(`Failed to handle URI ${uri.toString()}: ${message}`); @@ -66,7 +56,6 @@ async function routeUri( serviceContainer: ServiceContainer, deploymentManager: DeploymentManager, commands: Commands, - chatPanelProvider: ChatPanelProvider, ): Promise { const handler = routes[uri.path]; if (!handler) { @@ -78,7 +67,6 @@ async function routeUri( serviceContainer, deploymentManager, commands, - chatPanelProvider, }); } @@ -201,17 +189,6 @@ async function setupDeployment( }); } -async function handleOpenChat(ctx: UriRouteContext): Promise { - const { params, serviceContainer, deploymentManager, chatPanelProvider } = - ctx; - - const agentId = getRequiredParam(params, "agentId"); - - await setupDeployment(params, serviceContainer, deploymentManager); - - chatPanelProvider.openChat(agentId); -} - async function handleOAuthCallback(ctx: UriRouteContext): Promise { const { params, serviceContainer } = ctx; const logger = serviceContainer.getLogger(); diff --git a/test/unit/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts index 203b628e..b8f0c3e5 100644 --- a/test/unit/uri/uriHandler.test.ts +++ b/test/unit/uri/uriHandler.test.ts @@ -6,7 +6,6 @@ import { SecretsManager } from "@/core/secretsManager"; import { CALLBACK_PATH } from "@/oauth/utils"; import { maybeAskUrl } from "@/promptUtils"; import { registerUriHandler } from "@/uri/uriHandler"; -import { type ChatPanelProvider } from "@/webviews/chat/chatPanelProvider"; import { createMockLogger, @@ -99,13 +98,10 @@ function createTestContext() { .mocked(vscode.window.showErrorMessage) .mockResolvedValue(undefined); - const chatPanelProvider = {} as unknown as ChatPanelProvider; - registerUriHandler( container, deploymentManager as unknown as DeploymentManager, commands as unknown as Commands, - chatPanelProvider, ); return { From 59f1aad4f75616efd7bc9799762341a839a5d21b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 18 Mar 2026 11:54:55 +0000 Subject: [PATCH 14/16] refactor: rename agentId param to chatId Avoids confusion with the workspace agent param that the /open deep link already accepts. --- src/core/mementoManager.ts | 20 ++++++++++---------- src/extension.ts | 7 +++---- src/uri/uriHandler.ts | 8 ++++---- src/webviews/chat/chatPanelProvider.ts | 10 +++++----- 4 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/core/mementoManager.ts b/src/core/mementoManager.ts index 11a3fcd9..ca6b1860 100644 --- a/src/core/mementoManager.ts +++ b/src/core/mementoManager.ts @@ -59,26 +59,26 @@ export class MementoManager { } /** - * Store a chat agent ID to open after a window reload. + * 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 agent ID is + * 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 setPendingChatAgentId(agentId: string): Promise { - await this.memento.update("pendingChatAgentId", agentId); + public async setPendingChatId(chatId: string): Promise { + await this.memento.update("pendingChatId", chatId); } /** - * Read and clear the pending chat agent ID. Returns + * Read and clear the pending chat ID. Returns * undefined if none was stored. */ - public async getAndClearPendingChatAgentId(): Promise { - const agentId = this.memento.get("pendingChatAgentId"); - if (agentId !== undefined) { - await this.memento.update("pendingChatAgentId", undefined); + public async getAndClearPendingChatId(): Promise { + const chatId = this.memento.get("pendingChatId"); + if (chatId !== undefined) { + await this.memento.update("pendingChatId", undefined); } - return agentId; + return chatId; } } diff --git a/src/extension.ts b/src/extension.ts index 3f633abf..df2ecd6a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -331,10 +331,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // If a deep link stored a chat agent ID before the // remote-authority reload, open it now that the // deployment is configured. - const pendingAgentId = - await mementoManager.getAndClearPendingChatAgentId(); - if (pendingAgentId) { - chatPanelProvider.openChat(pendingAgentId); + const pendingChatId = await mementoManager.getAndClearPendingChatId(); + if (pendingChatId) { + chatPanelProvider.openChat(pendingChatId); } } } catch (ex) { diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index 66933695..cab5c64c 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -89,13 +89,13 @@ async function handleOpen(ctx: UriRouteContext): Promise { params.has("openRecent") && (!params.get("openRecent") || params.get("openRecent") === "true"); - // Persist the chat agent ID before commands.open() triggers + // 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 agentId = params.get("agentId"); - if (agentId) { + const chatId = params.get("chatId"); + if (chatId) { const mementoManager = serviceContainer.getMementoManager(); - await mementoManager.setPendingChatAgentId(agentId); + await mementoManager.setPendingChatId(chatId); } await setupDeployment(params, serviceContainer, deploymentManager); diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index aef9b5b7..d363829e 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -25,7 +25,7 @@ export class ChatPanelProvider private view?: vscode.WebviewView; private disposables: vscode.Disposable[] = []; - private agentId: string | undefined; + private chatId: string | undefined; constructor( private readonly client: CoderApi, @@ -35,8 +35,8 @@ export class ChatPanelProvider /** * Called by the `/openChat` URI handler. */ - public openChat(agentId: string): void { - this.agentId = agentId; + public openChat(chatId: string): void { + this.chatId = chatId; this.refresh(); void vscode.commands.executeCommand("coder.chatPanel.focus"); } @@ -70,7 +70,7 @@ export class ChatPanelProvider } const webview = this.view.webview; - if (!this.agentId) { + if (!this.chatId) { webview.html = this.getNoAgentHtml(); return; } @@ -81,7 +81,7 @@ export class ChatPanelProvider return; } - const embedUrl = `${coderUrl}/agents/${this.agentId}/embed`; + const embedUrl = `${coderUrl}/agents/${this.chatId}/embed`; webview.html = this.getIframeHtml(embedUrl, coderUrl); } From 4e25332c371a37a56c7c23d20611021cfb801cfe Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 18 Mar 2026 11:58:14 +0000 Subject: [PATCH 15/16] fix: address PR review feedback - Update openChat comment (no longer called from /openChat route). - Use this.view?.show() instead of executeCommand for focus. - Add icon to coderChat view entry in package.json. - Pass allowedOrigin instead of '*' in postMessage to iframe. --- package.json | 3 ++- src/webviews/chat/chatPanelProvider.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 4c528e7c..f97535d0 100644 --- a/package.json +++ b/package.json @@ -236,7 +236,8 @@ { "type": "webview", "id": "coder.chatPanel", - "name": "Coder Chat" + "name": "Coder Chat", + "icon": "media/shorthand-logo.svg" } ] }, diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index d363829e..4ac6e360 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -1,9 +1,10 @@ import { randomBytes } from "node:crypto"; -import * as vscode from "vscode"; 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: @@ -33,12 +34,14 @@ export class ChatPanelProvider ) {} /** - * Called by the `/openChat` URI handler. + * 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(); - void vscode.commands.executeCommand("coder.chatPanel.focus"); + this.view?.show(true); } resolveWebviewView( @@ -167,7 +170,7 @@ export class ChatPanelProvider iframe.contentWindow.postMessage({ type: 'coder:vscode-auth-bootstrap', payload: { token: data.token }, - }, '*'); + }, '${allowedOrigin}'); } }); })(); From de228dd5e13be5e477007986e066c1780e180d4c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 18 Mar 2026 12:02:18 +0000 Subject: [PATCH 16/16] chore: rename Coder Chat to Coder Chat (Experimental) --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f97535d0..c866d1ea 100644 --- a/package.json +++ b/package.json @@ -196,7 +196,7 @@ "panel": [ { "id": "coderChat", - "title": "Coder Chat", + "title": "Coder Chat (Experimental)", "icon": "media/shorthand-logo.svg" } ] @@ -236,7 +236,7 @@ { "type": "webview", "id": "coder.chatPanel", - "name": "Coder Chat", + "name": "Coder Chat (Experimental)", "icon": "media/shorthand-logo.svg" } ]