From 73127e7b3c0111ef193b385ef681afff21377cc3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 09:04:26 +0000 Subject: [PATCH 1/7] feat: move Coder Chat to secondary sidebar with agents experiment gate - Move the coderChat view container from the bottom panel to the secondarySidebar contribution point (requires VS Code >= 1.106). - Add coder.agentsEnabled context key, set by fetching /api/v2/experiments after login. The chat view is only visible when the agents experiment is enabled on the deployment. - Bump engines.vscode from ^1.95.0 to ^1.106.0. --- package.json | 5 +++-- src/core/contextManager.ts | 1 + src/deployment/deploymentManager.ts | 22 ++++++++++++++++++++++ test/mocks/testHelpers.ts | 12 +++++++++++- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e0d15cb3..12daeed0 100644 --- a/package.json +++ b/package.json @@ -193,7 +193,7 @@ "icon": "media/tasks-logo.svg" } ], - "panel": [ + "secondarySidebar": [ { "id": "coderChat", "title": "Coder Chat (Experimental)", @@ -237,7 +237,8 @@ "type": "webview", "id": "coder.chatPanel", "name": "Coder Chat (Experimental)", - "icon": "media/shorthand-logo.svg" + "icon": "media/shorthand-logo.svg", + "when": "coder.agentsEnabled" } ] }, diff --git a/src/core/contextManager.ts b/src/core/contextManager.ts index 60d3cfa6..8facb2ea 100644 --- a/src/core/contextManager.ts +++ b/src/core/contextManager.ts @@ -4,6 +4,7 @@ const CONTEXT_DEFAULTS = { "coder.authenticated": false, "coder.isOwner": false, "coder.loaded": false, + "coder.agentsEnabled": false, "coder.workspace.connected": false, "coder.workspace.updatable": false, } as const; diff --git a/src/deployment/deploymentManager.ts b/src/deployment/deploymentManager.ts index 4922d5a3..0de137d1 100644 --- a/src/deployment/deploymentManager.ts +++ b/src/deployment/deploymentManager.ts @@ -137,6 +137,7 @@ export class DeploymentManager implements vscode.Disposable { this.registerAuthListener(); // Contexts must be set before refresh (providers check isAuthenticated) this.updateAuthContexts(deployment.user); + this.updateExperimentContexts(); this.refreshWorkspaces(); const deploymentWithoutAuth: Deployment = @@ -166,6 +167,7 @@ export class DeploymentManager implements vscode.Disposable { this.oauthSessionManager.clearDeployment(); this.client.setCredentials(undefined, undefined); this.updateAuthContexts(undefined); + this.contextManager.set("coder.agentsEnabled", false); this.clearWorkspaces(); } @@ -251,6 +253,26 @@ export class DeploymentManager implements vscode.Disposable { this.contextManager.set("coder.isOwner", isOwner); } + /** + * Fetch enabled experiments and update context keys. + * Runs in the background so it does not block login. + */ + private updateExperimentContexts(): void { + this.client + .getExperiments() + .then((experiments) => { + this.contextManager.set( + "coder.agentsEnabled", + experiments.includes("agents"), + ); + }) + .catch((err) => { + this.logger.warn("Failed to fetch experiments", err); + // Default to hidden when we cannot determine. + this.contextManager.set("coder.agentsEnabled", false); + }); + } + /** * Refresh all workspace providers asynchronously. */ diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 9cc093f4..67fc6f5d 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -8,7 +8,7 @@ import axios, { import { vi } from "vitest"; import * as vscode from "vscode"; -import type { User } from "coder/site/src/api/typesGenerated"; +import type { Experiment, User } from "coder/site/src/api/typesGenerated"; import type { IncomingMessage } from "node:http"; import type { CoderApi } from "@/api/coderApi"; @@ -504,6 +504,7 @@ export class MockCoderApi implements Pick< | "getHost" | "getAuthenticatedUser" | "dispose" + | "getExperiments" > { private _host: string | undefined; private _token: string | undefined; @@ -541,6 +542,15 @@ export class MockCoderApi implements Pick< this._disposed = true; }); + // Minimal axios-like stub for getAxiosInstance(). + readonly getAxiosInstance = vi.fn(() => ({ + get: vi.fn().mockResolvedValue({ data: [] }), + })); + + readonly getExperiments = vi.fn( + (): Promise => Promise.resolve([]), + ); + /** * Get current host (for assertions) */ From 7b178df8aeba4f6da46dafa731f15a0820cf6b71 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 09:56:43 +0000 Subject: [PATCH 2/7] fix: resolve CI lint and integration test failures - Cast getAxiosInstance mock return to AxiosInstance to satisfy TypeScript strict type checking. - Bump CI integration test matrix from VS Code 1.95.0 to 1.106.0 to match the new engines.vscode minimum. --- test/mocks/testHelpers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 67fc6f5d..9530b36b 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -2,6 +2,7 @@ import axios, { AxiosError, AxiosHeaders, type AxiosAdapter, + type AxiosInstance, type AxiosResponse, type InternalAxiosRequestConfig, } from "axios"; @@ -545,7 +546,7 @@ export class MockCoderApi implements Pick< // Minimal axios-like stub for getAxiosInstance(). readonly getAxiosInstance = vi.fn(() => ({ get: vi.fn().mockResolvedValue({ data: [] }), - })); + }) as unknown as AxiosInstance); readonly getExperiments = vi.fn( (): Promise => Promise.resolve([]), From b2ba0ef5c0aa1358a2fb81de190e19d868922a7b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 10:00:39 +0000 Subject: [PATCH 3/7] fix: prettier formatting and vscode-test config for 1.106.0 - Apply prettier formatting to deploymentManager.ts and testHelpers.ts. - Update .vscode-test.mjs minimum version from 1.95.0 to 1.106.0 to match the CI matrix and engines.vscode requirement. --- test/mocks/testHelpers.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 9530b36b..0e644016 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -544,9 +544,12 @@ export class MockCoderApi implements Pick< }); // Minimal axios-like stub for getAxiosInstance(). - readonly getAxiosInstance = vi.fn(() => ({ - get: vi.fn().mockResolvedValue({ data: [] }), - }) as unknown as AxiosInstance); + readonly getAxiosInstance = vi.fn( + () => + ({ + get: vi.fn().mockResolvedValue({ data: [] }), + }) as unknown as AxiosInstance, + ); readonly getExperiments = vi.fn( (): Promise => Promise.resolve([]), From d01e49403e20015f0b36ece06b59ad08db011bdc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 10:51:36 +0000 Subject: [PATCH 4/7] fix: clear pending chat ID when open flow is abandoned If commands.open() returns without actually opening a window (e.g. the user cancels a workspace, agent, or folder prompt), clear the pending chat ID from memento so it does not leak into a future, unrelated reload. - commands.open() and openWorkspace() now return boolean indicating whether a window was actually opened. - handleOpen() clears the pending chat ID when open() returns false. - Add clearPendingChatId() to MementoManager. --- src/commands.ts | 25 ++++++++++++++++--------- src/core/mementoManager.ts | 9 +++++++++ src/uri/uriHandler.ts | 11 +++++++++-- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 3357f456..84227f10 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -430,7 +430,7 @@ export class Commands { * Throw if not logged into a deployment. */ - public async openFromSidebar(item: OpenableTreeItem) { + public async openFromSidebar(item: OpenableTreeItem): Promise { if (item) { const baseUrl = this.extensionClient.getAxiosInstance().defaults.baseURL; if (!baseUrl) { @@ -464,7 +464,7 @@ export class Commands { } else { // If there is no tree item, then the user manually ran this command. // Default to the regular open instead. - return this.open(); + await this.open(); } } @@ -529,7 +529,7 @@ export class Commands { agentName?: string, folderPath?: string, openRecent?: boolean, - ): Promise { + ): Promise { const baseUrl = this.extensionClient.getAxiosInstance().defaults.baseURL; if (!baseUrl) { throw new Error("You are not logged in"); @@ -545,7 +545,7 @@ export class Commands { workspace = await this.pickWorkspace(); if (!workspace) { // User declined to pick a workspace. - return; + return false; } } @@ -553,10 +553,16 @@ export class Commands { const agent = await maybeAskAgent(agents, agentName); if (!agent) { // User declined to pick an agent. - return; + return false; } - await this.openWorkspace(baseUrl, workspace, agent, folderPath, openRecent); + return this.openWorkspace( + baseUrl, + workspace, + agent, + folderPath, + openRecent, + ); } /** @@ -745,7 +751,7 @@ export class Commands { agent: WorkspaceAgent, folderPath: string | undefined, openRecent = false, - ) { + ): Promise { const remoteAuthority = toRemoteAuthority( baseUrl, workspace.owner_name, @@ -788,7 +794,7 @@ export class Commands { }); if (!folderPath) { // User aborted. - return; + return false; } } } @@ -806,7 +812,7 @@ export class Commands { // Open this in a new window! newWindow, ); - return; + return true; } // This opens the workspace without an active folder opened. @@ -814,6 +820,7 @@ export class Commands { remoteAuthority: remoteAuthority, reuseWindow: !newWindow, }); + return true; } } diff --git a/src/core/mementoManager.ts b/src/core/mementoManager.ts index ca6b1860..fbcd3f1d 100644 --- a/src/core/mementoManager.ts +++ b/src/core/mementoManager.ts @@ -81,4 +81,13 @@ export class MementoManager { } return chatId; } + + /** + * Clear the pending chat ID without reading it. Used when + * the open flow is abandoned (e.g. user cancels a prompt) + * so the stale ID does not leak into a future reload. + */ + public async clearPendingChatId(): Promise { + await this.memento.update("pendingChatId", undefined); + } } diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index cab5c64c..4955fed4 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -93,20 +93,27 @@ async function handleOpen(ctx: UriRouteContext): Promise { // a remote-authority reload that wipes in-memory state. // The extension picks this up after the reload in activate(). const chatId = params.get("chatId"); + const mementoManager = serviceContainer.getMementoManager(); if (chatId) { - const mementoManager = serviceContainer.getMementoManager(); await mementoManager.setPendingChatId(chatId); } await setupDeployment(params, serviceContainer, deploymentManager); - await commands.open( + const opened = await commands.open( owner, workspace, agent ?? undefined, folder ?? undefined, openRecent, ); + + // If commands.open() returned without opening a window (e.g. the + // user cancelled a prompt), clear the pending chat ID so it does + // not leak into a future, unrelated reload. + if (!opened && chatId) { + await mementoManager.clearPendingChatId(); + } } async function handleOpenDevContainer(ctx: UriRouteContext): Promise { From 52e8e495a9b4921d87257ca216bf3308e88c970c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 11:14:45 +0000 Subject: [PATCH 5/7] fix: retry auth token delivery to chat iframe with backoff When the chat iframe sends coder:vscode-ready, the session token may not be available yet if deployment setup is still in progress after a reload. Previously, handleMessage silently returned, leaving the iframe stuck on 'Authenticating...' forever with no recovery. Now sendAuthToken retries up to 5 times with exponential backoff (500ms, 1s, 2s, 4s, 8s). If the token is still unavailable after all retries, a coder:auth-error message is sent to the webview, which shows the error and a Retry button so the user can try again after signing in. --- src/webviews/chat/chatPanelProvider.ts | 68 +++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index 4ac6e360..a1672294 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -94,19 +94,46 @@ export class ChatPanelProvider } 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", + this.sendAuthToken(); + } + } + + /** + * Attempt to forward the session token to the chat iframe. + * The token may not be available immediately after a reload + * (e.g. deployment setup is still in progress), so we retry + * with exponential backoff before giving up. + */ + private static readonly MAX_AUTH_RETRIES = 5; + private static readonly AUTH_RETRY_BASE_MS = 500; + + private sendAuthToken(attempt = 0): void { + const token = this.client.getSessionToken(); + if (!token) { + if (attempt < ChatPanelProvider.MAX_AUTH_RETRIES) { + const delay = ChatPanelProvider.AUTH_RETRY_BASE_MS * 2 ** attempt; + this.logger.info( + `Chat: no session token yet, retrying in ${delay}ms ` + + `(attempt ${attempt + 1}/${ChatPanelProvider.MAX_AUTH_RETRIES})`, ); + setTimeout(() => this.sendAuthToken(attempt + 1), delay); return; } - this.logger.info("Chat: forwarding token to iframe"); + this.logger.warn( + "Chat iframe requested auth but no session token available " + + "after all retries", + ); this.view?.webview.postMessage({ - type: "coder:auth-bootstrap-token", - token, + type: "coder:auth-error", + error: "No session token available. Please sign in and retry.", }); + 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 { @@ -136,6 +163,17 @@ export class ChatPanelProvider font-family: var(--vscode-font-family, sans-serif); font-size: 13px; padding: 16px; text-align: center; } + #retry-btn { + margin-top: 12px; padding: 6px 16px; + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, #fff); + border: none; border-radius: 2px; cursor: pointer; + font-family: var(--vscode-font-family, sans-serif); + font-size: 13px; + } + #retry-btn:hover { + background: var(--vscode-button-hoverBackground, #1177bb); + } @@ -172,6 +210,22 @@ export class ChatPanelProvider payload: { token: data.token }, }, '${allowedOrigin}'); } + + if (data.type === 'coder:auth-error') { + status.innerHTML = ''; + status.appendChild(document.createTextNode(data.error || 'Authentication failed.')); + const btn = document.createElement('button'); + btn.id = 'retry-btn'; + btn.textContent = 'Retry'; + btn.addEventListener('click', () => { + status.textContent = 'Authenticating…'; + vscode.postMessage({ type: 'coder:vscode-ready' }); + }); + status.appendChild(document.createElement('br')); + status.appendChild(btn); + status.style.display = 'block'; + iframe.style.display = 'none'; + } }); })(); From d5cbc427a0ad09d6bcc44babb22c889594bd74da Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Mar 2026 14:30:20 +0000 Subject: [PATCH 6/7] fix: address review feedback - Trim verbose JSDoc on mementoManager chat ID methods. - Wrap commands.open() in try/finally so the pending chat ID is cleared even if it throws. - Track auth retry timer ID and clear it on dispose. - Use textContent instead of innerHTML in webview error handler. --- src/core/mementoManager.ts | 20 +++--------------- src/uri/uriHandler.ts | 28 ++++++++++++++------------ src/webviews/chat/chatPanelProvider.ts | 9 +++++++-- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/core/mementoManager.ts b/src/core/mementoManager.ts index fbcd3f1d..b3b9c6df 100644 --- a/src/core/mementoManager.ts +++ b/src/core/mementoManager.ts @@ -58,22 +58,12 @@ 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. - */ + /** Store a chat ID to open after a remote-authority 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. - */ + /** Read and clear the pending chat ID (undefined if none). */ public async getAndClearPendingChatId(): Promise { const chatId = this.memento.get("pendingChatId"); if (chatId !== undefined) { @@ -82,11 +72,7 @@ export class MementoManager { return chatId; } - /** - * Clear the pending chat ID without reading it. Used when - * the open flow is abandoned (e.g. user cancels a prompt) - * so the stale ID does not leak into a future reload. - */ + /** Clear the pending chat ID without reading it. */ public async clearPendingChatId(): Promise { await this.memento.update("pendingChatId", undefined); } diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index 4955fed4..6902c37f 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -100,19 +100,21 @@ async function handleOpen(ctx: UriRouteContext): Promise { await setupDeployment(params, serviceContainer, deploymentManager); - const opened = await commands.open( - owner, - workspace, - agent ?? undefined, - folder ?? undefined, - openRecent, - ); - - // If commands.open() returned without opening a window (e.g. the - // user cancelled a prompt), clear the pending chat ID so it does - // not leak into a future, unrelated reload. - if (!opened && chatId) { - await mementoManager.clearPendingChatId(); + let opened = false; + try { + opened = await commands.open( + owner, + workspace, + agent ?? undefined, + folder ?? undefined, + openRecent, + ); + } finally { + // Clear the pending chat ID if commands.open() did not + // actually open a window (user cancelled, or it threw). + if (!opened && chatId) { + await mementoManager.clearPendingChatId(); + } } } diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index a1672294..5ad62376 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -27,6 +27,7 @@ export class ChatPanelProvider private view?: vscode.WebviewView; private disposables: vscode.Disposable[] = []; private chatId: string | undefined; + private authRetryTimer: ReturnType | undefined; constructor( private readonly client: CoderApi, @@ -116,7 +117,10 @@ export class ChatPanelProvider `Chat: no session token yet, retrying in ${delay}ms ` + `(attempt ${attempt + 1}/${ChatPanelProvider.MAX_AUTH_RETRIES})`, ); - setTimeout(() => this.sendAuthToken(attempt + 1), delay); + this.authRetryTimer = setTimeout( + () => this.sendAuthToken(attempt + 1), + delay, + ); return; } this.logger.warn( @@ -212,7 +216,7 @@ export class ChatPanelProvider } if (data.type === 'coder:auth-error') { - status.innerHTML = ''; + status.textContent = ''; status.appendChild(document.createTextNode(data.error || 'Authentication failed.')); const btn = document.createElement('button'); btn.id = 'retry-btn'; @@ -244,6 +248,7 @@ text-align:center;} } dispose(): void { + clearTimeout(this.authRetryTimer); for (const d of this.disposables) { d.dispose(); } From 61cb9618f89adc9a0182d30233f8f6e354d23f84 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 24 Mar 2026 18:51:32 +0300 Subject: [PATCH 7/7] Fix smol issues --- src/deployment/deploymentManager.ts | 4 +++- src/webviews/chat/chatPanelProvider.ts | 3 ++- test/mocks/testHelpers.ts | 9 --------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/deployment/deploymentManager.ts b/src/deployment/deploymentManager.ts index 0de137d1..9eaf2ccf 100644 --- a/src/deployment/deploymentManager.ts +++ b/src/deployment/deploymentManager.ts @@ -261,6 +261,9 @@ export class DeploymentManager implements vscode.Disposable { this.client .getExperiments() .then((experiments) => { + if (!this.isAuthenticated()) { + return; + } this.contextManager.set( "coder.agentsEnabled", experiments.includes("agents"), @@ -268,7 +271,6 @@ export class DeploymentManager implements vscode.Disposable { }) .catch((err) => { this.logger.warn("Failed to fetch experiments", err); - // Default to hidden when we cannot determine. this.contextManager.set("coder.agentsEnabled", false); }); } diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index 5ad62376..5a44dd90 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -109,6 +109,7 @@ export class ChatPanelProvider private static readonly AUTH_RETRY_BASE_MS = 500; private sendAuthToken(attempt = 0): void { + clearTimeout(this.authRetryTimer); const token = this.client.getSessionToken(); if (!token) { if (attempt < ChatPanelProvider.MAX_AUTH_RETRIES) { @@ -212,7 +213,7 @@ export class ChatPanelProvider iframe.contentWindow.postMessage({ type: 'coder:vscode-auth-bootstrap', payload: { token: data.token }, - }, '${allowedOrigin}'); + }, '${allowedOrigin}'); } if (data.type === 'coder:auth-error') { diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 0e644016..5e4487af 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -2,7 +2,6 @@ import axios, { AxiosError, AxiosHeaders, type AxiosAdapter, - type AxiosInstance, type AxiosResponse, type InternalAxiosRequestConfig, } from "axios"; @@ -543,14 +542,6 @@ export class MockCoderApi implements Pick< this._disposed = true; }); - // Minimal axios-like stub for getAxiosInstance(). - readonly getAxiosInstance = vi.fn( - () => - ({ - get: vi.fn().mockResolvedValue({ data: [] }), - }) as unknown as AxiosInstance, - ); - readonly getExperiments = vi.fn( (): Promise => Promise.resolve([]), );