Skip to content

Commit 9f481ff

Browse files
authored
feat: gate chat panel on agents experiment and improve auth resilience (#851)
- Fetch experiments on login to set coder.agentsEnabled context, controlling chat panel visibility in the sidebar - Retry auth token delivery to chat iframe with exponential backoff (up to 5 attempts) when the token isn't available immediately after reload - Show retry button in chat webview when auth fails after all attempts - Return boolean from commands.open() so the URI handler can clear pendingChatId when the user cancels or the open fails - Guard experiment context update against logout race
1 parent b518e39 commit 9f481ff

File tree

8 files changed

+142
-40
lines changed

8 files changed

+142
-40
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@
193193
"icon": "media/tasks-logo.svg"
194194
}
195195
],
196-
"panel": [
196+
"secondarySidebar": [
197197
{
198198
"id": "coderChat",
199199
"title": "Coder Chat (Experimental)",
@@ -237,7 +237,8 @@
237237
"type": "webview",
238238
"id": "coder.chatPanel",
239239
"name": "Coder Chat (Experimental)",
240-
"icon": "media/shorthand-logo.svg"
240+
"icon": "media/shorthand-logo.svg",
241+
"when": "coder.agentsEnabled"
241242
}
242243
]
243244
},

src/commands.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ export class Commands {
430430
431431
* Throw if not logged into a deployment.
432432
*/
433-
public async openFromSidebar(item: OpenableTreeItem) {
433+
public async openFromSidebar(item: OpenableTreeItem): Promise<void> {
434434
if (item) {
435435
const baseUrl = this.extensionClient.getAxiosInstance().defaults.baseURL;
436436
if (!baseUrl) {
@@ -464,7 +464,7 @@ export class Commands {
464464
} else {
465465
// If there is no tree item, then the user manually ran this command.
466466
// Default to the regular open instead.
467-
return this.open();
467+
await this.open();
468468
}
469469
}
470470

@@ -529,7 +529,7 @@ export class Commands {
529529
agentName?: string,
530530
folderPath?: string,
531531
openRecent?: boolean,
532-
): Promise<void> {
532+
): Promise<boolean> {
533533
const baseUrl = this.extensionClient.getAxiosInstance().defaults.baseURL;
534534
if (!baseUrl) {
535535
throw new Error("You are not logged in");
@@ -545,18 +545,24 @@ export class Commands {
545545
workspace = await this.pickWorkspace();
546546
if (!workspace) {
547547
// User declined to pick a workspace.
548-
return;
548+
return false;
549549
}
550550
}
551551

552552
const agents = await this.extractAgentsWithFallback(workspace);
553553
const agent = await maybeAskAgent(agents, agentName);
554554
if (!agent) {
555555
// User declined to pick an agent.
556-
return;
556+
return false;
557557
}
558558

559-
await this.openWorkspace(baseUrl, workspace, agent, folderPath, openRecent);
559+
return this.openWorkspace(
560+
baseUrl,
561+
workspace,
562+
agent,
563+
folderPath,
564+
openRecent,
565+
);
560566
}
561567

562568
/**
@@ -745,7 +751,7 @@ export class Commands {
745751
agent: WorkspaceAgent,
746752
folderPath: string | undefined,
747753
openRecent = false,
748-
) {
754+
): Promise<boolean> {
749755
const remoteAuthority = toRemoteAuthority(
750756
baseUrl,
751757
workspace.owner_name,
@@ -788,7 +794,7 @@ export class Commands {
788794
});
789795
if (!folderPath) {
790796
// User aborted.
791-
return;
797+
return false;
792798
}
793799
}
794800
}
@@ -806,14 +812,15 @@ export class Commands {
806812
// Open this in a new window!
807813
newWindow,
808814
);
809-
return;
815+
return true;
810816
}
811817

812818
// This opens the workspace without an active folder opened.
813819
await vscode.commands.executeCommand("vscode.newWindow", {
814820
remoteAuthority: remoteAuthority,
815821
reuseWindow: !newWindow,
816822
});
823+
return true;
817824
}
818825
}
819826

src/core/contextManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const CONTEXT_DEFAULTS = {
44
"coder.authenticated": false,
55
"coder.isOwner": false,
66
"coder.loaded": false,
7+
"coder.agentsEnabled": false,
78
"coder.workspace.connected": false,
89
"coder.workspace.updatable": false,
910
} as const;

src/core/mementoManager.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,27 +58,22 @@ export class MementoManager {
5858
return isFirst === true;
5959
}
6060

61-
/**
62-
* Store a chat ID to open after a window reload.
63-
* Used by the /open deep link handler: it must call
64-
* commands.open() which triggers a remote-authority
65-
* reload, wiping in-memory state. The chat ID is
66-
* persisted here so the extension can pick it up on
67-
* the other side of the reload.
68-
*/
61+
/** Store a chat ID to open after a remote-authority reload. */
6962
public async setPendingChatId(chatId: string): Promise<void> {
7063
await this.memento.update("pendingChatId", chatId);
7164
}
7265

73-
/**
74-
* Read and clear the pending chat ID. Returns
75-
* undefined if none was stored.
76-
*/
66+
/** Read and clear the pending chat ID (undefined if none). */
7767
public async getAndClearPendingChatId(): Promise<string | undefined> {
7868
const chatId = this.memento.get<string>("pendingChatId");
7969
if (chatId !== undefined) {
8070
await this.memento.update("pendingChatId", undefined);
8171
}
8272
return chatId;
8373
}
74+
75+
/** Clear the pending chat ID without reading it. */
76+
public async clearPendingChatId(): Promise<void> {
77+
await this.memento.update("pendingChatId", undefined);
78+
}
8479
}

src/deployment/deploymentManager.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export class DeploymentManager implements vscode.Disposable {
137137
this.registerAuthListener();
138138
// Contexts must be set before refresh (providers check isAuthenticated)
139139
this.updateAuthContexts(deployment.user);
140+
this.updateExperimentContexts();
140141
this.refreshWorkspaces();
141142

142143
const deploymentWithoutAuth: Deployment =
@@ -166,6 +167,7 @@ export class DeploymentManager implements vscode.Disposable {
166167
this.oauthSessionManager.clearDeployment();
167168
this.client.setCredentials(undefined, undefined);
168169
this.updateAuthContexts(undefined);
170+
this.contextManager.set("coder.agentsEnabled", false);
169171
this.clearWorkspaces();
170172
}
171173

@@ -251,6 +253,28 @@ export class DeploymentManager implements vscode.Disposable {
251253
this.contextManager.set("coder.isOwner", isOwner);
252254
}
253255

256+
/**
257+
* Fetch enabled experiments and update context keys.
258+
* Runs in the background so it does not block login.
259+
*/
260+
private updateExperimentContexts(): void {
261+
this.client
262+
.getExperiments()
263+
.then((experiments) => {
264+
if (!this.isAuthenticated()) {
265+
return;
266+
}
267+
this.contextManager.set(
268+
"coder.agentsEnabled",
269+
experiments.includes("agents"),
270+
);
271+
})
272+
.catch((err) => {
273+
this.logger.warn("Failed to fetch experiments", err);
274+
this.contextManager.set("coder.agentsEnabled", false);
275+
});
276+
}
277+
254278
/**
255279
* Refresh all workspace providers asynchronously.
256280
*/

src/uri/uriHandler.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,20 +93,29 @@ async function handleOpen(ctx: UriRouteContext): Promise<void> {
9393
// a remote-authority reload that wipes in-memory state.
9494
// The extension picks this up after the reload in activate().
9595
const chatId = params.get("chatId");
96+
const mementoManager = serviceContainer.getMementoManager();
9697
if (chatId) {
97-
const mementoManager = serviceContainer.getMementoManager();
9898
await mementoManager.setPendingChatId(chatId);
9999
}
100100

101101
await setupDeployment(params, serviceContainer, deploymentManager);
102102

103-
await commands.open(
104-
owner,
105-
workspace,
106-
agent ?? undefined,
107-
folder ?? undefined,
108-
openRecent,
109-
);
103+
let opened = false;
104+
try {
105+
opened = await commands.open(
106+
owner,
107+
workspace,
108+
agent ?? undefined,
109+
folder ?? undefined,
110+
openRecent,
111+
);
112+
} finally {
113+
// Clear the pending chat ID if commands.open() did not
114+
// actually open a window (user cancelled, or it threw).
115+
if (!opened && chatId) {
116+
await mementoManager.clearPendingChatId();
117+
}
118+
}
110119
}
111120

112121
async function handleOpenDevContainer(ctx: UriRouteContext): Promise<void> {

src/webviews/chat/chatPanelProvider.ts

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export class ChatPanelProvider
2727
private view?: vscode.WebviewView;
2828
private disposables: vscode.Disposable[] = [];
2929
private chatId: string | undefined;
30+
private authRetryTimer: ReturnType<typeof setTimeout> | undefined;
3031

3132
constructor(
3233
private readonly client: CoderApi,
@@ -94,19 +95,50 @@ export class ChatPanelProvider
9495
}
9596
const msg = message as { type?: string };
9697
if (msg.type === "coder:vscode-ready") {
97-
const token = this.client.getSessionToken();
98-
if (!token) {
99-
this.logger.warn(
100-
"Chat iframe requested auth but no session token available",
98+
this.sendAuthToken();
99+
}
100+
}
101+
102+
/**
103+
* Attempt to forward the session token to the chat iframe.
104+
* The token may not be available immediately after a reload
105+
* (e.g. deployment setup is still in progress), so we retry
106+
* with exponential backoff before giving up.
107+
*/
108+
private static readonly MAX_AUTH_RETRIES = 5;
109+
private static readonly AUTH_RETRY_BASE_MS = 500;
110+
111+
private sendAuthToken(attempt = 0): void {
112+
clearTimeout(this.authRetryTimer);
113+
const token = this.client.getSessionToken();
114+
if (!token) {
115+
if (attempt < ChatPanelProvider.MAX_AUTH_RETRIES) {
116+
const delay = ChatPanelProvider.AUTH_RETRY_BASE_MS * 2 ** attempt;
117+
this.logger.info(
118+
`Chat: no session token yet, retrying in ${delay}ms ` +
119+
`(attempt ${attempt + 1}/${ChatPanelProvider.MAX_AUTH_RETRIES})`,
120+
);
121+
this.authRetryTimer = setTimeout(
122+
() => this.sendAuthToken(attempt + 1),
123+
delay,
101124
);
102125
return;
103126
}
104-
this.logger.info("Chat: forwarding token to iframe");
127+
this.logger.warn(
128+
"Chat iframe requested auth but no session token available " +
129+
"after all retries",
130+
);
105131
this.view?.webview.postMessage({
106-
type: "coder:auth-bootstrap-token",
107-
token,
132+
type: "coder:auth-error",
133+
error: "No session token available. Please sign in and retry.",
108134
});
135+
return;
109136
}
137+
this.logger.info("Chat: forwarding token to iframe");
138+
this.view?.webview.postMessage({
139+
type: "coder:auth-bootstrap-token",
140+
token,
141+
});
110142
}
111143

112144
private getIframeHtml(embedUrl: string, allowedOrigin: string): string {
@@ -136,6 +168,17 @@ export class ChatPanelProvider
136168
font-family: var(--vscode-font-family, sans-serif);
137169
font-size: 13px; padding: 16px; text-align: center;
138170
}
171+
#retry-btn {
172+
margin-top: 12px; padding: 6px 16px;
173+
background: var(--vscode-button-background, #0e639c);
174+
color: var(--vscode-button-foreground, #fff);
175+
border: none; border-radius: 2px; cursor: pointer;
176+
font-family: var(--vscode-font-family, sans-serif);
177+
font-size: 13px;
178+
}
179+
#retry-btn:hover {
180+
background: var(--vscode-button-hoverBackground, #1177bb);
181+
}
139182
</style>
140183
</head>
141184
<body>
@@ -170,7 +213,23 @@ export class ChatPanelProvider
170213
iframe.contentWindow.postMessage({
171214
type: 'coder:vscode-auth-bootstrap',
172215
payload: { token: data.token },
173-
}, '${allowedOrigin}');
216+
}, '${allowedOrigin}');
217+
}
218+
219+
if (data.type === 'coder:auth-error') {
220+
status.textContent = '';
221+
status.appendChild(document.createTextNode(data.error || 'Authentication failed.'));
222+
const btn = document.createElement('button');
223+
btn.id = 'retry-btn';
224+
btn.textContent = 'Retry';
225+
btn.addEventListener('click', () => {
226+
status.textContent = 'Authenticating…';
227+
vscode.postMessage({ type: 'coder:vscode-ready' });
228+
});
229+
status.appendChild(document.createElement('br'));
230+
status.appendChild(btn);
231+
status.style.display = 'block';
232+
iframe.style.display = 'none';
174233
}
175234
});
176235
})();
@@ -190,6 +249,7 @@ text-align:center;}</style></head>
190249
}
191250

192251
dispose(): void {
252+
clearTimeout(this.authRetryTimer);
193253
for (const d of this.disposables) {
194254
d.dispose();
195255
}

test/mocks/testHelpers.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import axios, {
88
import { vi } from "vitest";
99
import * as vscode from "vscode";
1010

11-
import type { User } from "coder/site/src/api/typesGenerated";
11+
import type { Experiment, User } from "coder/site/src/api/typesGenerated";
1212
import type { IncomingMessage } from "node:http";
1313

1414
import type { CoderApi } from "@/api/coderApi";
@@ -504,6 +504,7 @@ export class MockCoderApi implements Pick<
504504
| "getHost"
505505
| "getAuthenticatedUser"
506506
| "dispose"
507+
| "getExperiments"
507508
> {
508509
private _host: string | undefined;
509510
private _token: string | undefined;
@@ -541,6 +542,10 @@ export class MockCoderApi implements Pick<
541542
this._disposed = true;
542543
});
543544

545+
readonly getExperiments = vi.fn(
546+
(): Promise<Experiment[]> => Promise.resolve([]),
547+
);
548+
544549
/**
545550
* Get current host (for assertions)
546551
*/

0 commit comments

Comments
 (0)