Skip to content

Commit 7f147ab

Browse files
authored
feat: embed Coder agent chat in VS Code sidebar panel (#844)
Adds a new **Coder Chat** webview panel that embeds the Coder agent chat UI directly inside VS Code, triggered via a deep link.
1 parent 0a7c58d commit 7f147ab

File tree

5 files changed

+266
-0
lines changed

5 files changed

+266
-0
lines changed

package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,13 @@
192192
"title": "Coder Tasks",
193193
"icon": "media/tasks-logo.svg"
194194
}
195+
],
196+
"panel": [
197+
{
198+
"id": "coderChat",
199+
"title": "Coder Chat (Experimental)",
200+
"icon": "media/shorthand-logo.svg"
201+
}
195202
]
196203
},
197204
"views": {
@@ -224,6 +231,14 @@
224231
"icon": "media/tasks-logo.svg",
225232
"when": "coder.authenticated"
226233
}
234+
],
235+
"coderChat": [
236+
{
237+
"type": "webview",
238+
"id": "coder.chatPanel",
239+
"name": "Coder Chat (Experimental)",
240+
"icon": "media/shorthand-logo.svg"
241+
}
227242
]
228243
},
229244
"viewsWelcome": [

src/core/mementoManager.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,28 @@ export class MementoManager {
5757
}
5858
return isFirst === true;
5959
}
60+
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+
*/
69+
public async setPendingChatId(chatId: string): Promise<void> {
70+
await this.memento.update("pendingChatId", chatId);
71+
}
72+
73+
/**
74+
* Read and clear the pending chat ID. Returns
75+
* undefined if none was stored.
76+
*/
77+
public async getAndClearPendingChatId(): Promise<string | undefined> {
78+
const chatId = this.memento.get<string>("pendingChatId");
79+
if (chatId !== undefined) {
80+
await this.memento.update("pendingChatId", undefined);
81+
}
82+
return chatId;
83+
}
6084
}

src/extension.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Remote } from "./remote/remote";
1919
import { getRemoteSshExtension } from "./remote/sshExtension";
2020
import { registerUriHandler } from "./uri/uriHandler";
2121
import { initVscodeProposed } from "./vscodeProposed";
22+
import { ChatPanelProvider } from "./webviews/chat/chatPanelProvider";
2223
import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider";
2324
import {
2425
WorkspaceProvider,
@@ -222,6 +223,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
222223
),
223224
);
224225

226+
// Register Chat embed panel with dependencies
227+
const chatPanelProvider = new ChatPanelProvider(client, output);
228+
ctx.subscriptions.push(
229+
chatPanelProvider,
230+
vscode.window.registerWebviewViewProvider(
231+
ChatPanelProvider.viewType,
232+
chatPanelProvider,
233+
{ webviewOptions: { retainContextWhenHidden: true } },
234+
),
235+
);
236+
225237
ctx.subscriptions.push(
226238
registerUriHandler(serviceContainer, deploymentManager, commands),
227239
vscode.commands.registerCommand(
@@ -315,6 +327,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
315327
url: details.url,
316328
token: details.token,
317329
});
330+
331+
// If a deep link stored a chat agent ID before the
332+
// remote-authority reload, open it now that the
333+
// deployment is configured.
334+
const pendingChatId = await mementoManager.getAndClearPendingChatId();
335+
if (pendingChatId) {
336+
chatPanelProvider.openChat(pendingChatId);
337+
}
318338
}
319339
} catch (ex) {
320340
if (ex instanceof CertificateError) {

src/uri/uriHandler.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@ async function handleOpen(ctx: UriRouteContext): Promise<void> {
8989
params.has("openRecent") &&
9090
(!params.get("openRecent") || params.get("openRecent") === "true");
9191

92+
// Persist the chat ID before commands.open() triggers
93+
// a remote-authority reload that wipes in-memory state.
94+
// The extension picks this up after the reload in activate().
95+
const chatId = params.get("chatId");
96+
if (chatId) {
97+
const mementoManager = serviceContainer.getMementoManager();
98+
await mementoManager.setPendingChatId(chatId);
99+
}
100+
92101
await setupDeployment(params, serviceContainer, deploymentManager);
93102

94103
await commands.open(
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { randomBytes } from "node:crypto";
2+
3+
import { type CoderApi } from "../../api/coderApi";
4+
import { type Logger } from "../../logging/logger";
5+
6+
import type * as vscode from "vscode";
7+
8+
/**
9+
* Provides a webview that embeds the Coder agent chat UI.
10+
* Authentication flows through postMessage:
11+
*
12+
* 1. The iframe loads /agents/{id}/embed on the Coder server.
13+
* 2. The embed page detects the user is signed out and sends
14+
* { type: "coder:vscode-ready" } to window.parent.
15+
* 3. Our webview relays this to the extension host.
16+
* 4. The extension host replies with the session token.
17+
* 5. The webview forwards { type: "coder:vscode-auth-bootstrap" }
18+
* with the token back into the iframe.
19+
* 6. The embed page calls API.setSessionToken(token), re-fetches
20+
* the authenticated user, and renders the chat UI.
21+
*/
22+
export class ChatPanelProvider
23+
implements vscode.WebviewViewProvider, vscode.Disposable
24+
{
25+
public static readonly viewType = "coder.chatPanel";
26+
27+
private view?: vscode.WebviewView;
28+
private disposables: vscode.Disposable[] = [];
29+
private chatId: string | undefined;
30+
31+
constructor(
32+
private readonly client: CoderApi,
33+
private readonly logger: Logger,
34+
) {}
35+
36+
/**
37+
* Opens the chat panel for the given chat ID.
38+
* Called after a deep link reload via the persisted
39+
* pendingChatId, or directly for testing.
40+
*/
41+
public openChat(chatId: string): void {
42+
this.chatId = chatId;
43+
this.refresh();
44+
this.view?.show(true);
45+
}
46+
47+
resolveWebviewView(
48+
webviewView: vscode.WebviewView,
49+
_context: vscode.WebviewViewResolveContext,
50+
_token: vscode.CancellationToken,
51+
): void {
52+
this.view = webviewView;
53+
webviewView.webview.options = { enableScripts: true };
54+
this.disposables.push(
55+
webviewView.webview.onDidReceiveMessage((msg: unknown) => {
56+
this.handleMessage(msg);
57+
}),
58+
);
59+
this.renderView();
60+
webviewView.onDidDispose(() => this.dispose());
61+
}
62+
63+
public refresh(): void {
64+
if (!this.view) {
65+
return;
66+
}
67+
this.renderView();
68+
}
69+
70+
private renderView(): void {
71+
if (!this.view) {
72+
throw new Error("renderView called before resolveWebviewView");
73+
}
74+
const webview = this.view.webview;
75+
76+
if (!this.chatId) {
77+
webview.html = this.getNoAgentHtml();
78+
return;
79+
}
80+
81+
const coderUrl = this.client.getHost();
82+
if (!coderUrl) {
83+
webview.html = this.getNoAgentHtml();
84+
return;
85+
}
86+
87+
const embedUrl = `${coderUrl}/agents/${this.chatId}/embed`;
88+
webview.html = this.getIframeHtml(embedUrl, coderUrl);
89+
}
90+
91+
private handleMessage(message: unknown): void {
92+
if (typeof message !== "object" || message === null) {
93+
return;
94+
}
95+
const msg = message as { type?: string };
96+
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",
101+
);
102+
return;
103+
}
104+
this.logger.info("Chat: forwarding token to iframe");
105+
this.view?.webview.postMessage({
106+
type: "coder:auth-bootstrap-token",
107+
token,
108+
});
109+
}
110+
}
111+
112+
private getIframeHtml(embedUrl: string, allowedOrigin: string): string {
113+
const nonce = randomBytes(16).toString("base64");
114+
115+
return /* html */ `<!DOCTYPE html>
116+
<html lang="en">
117+
<head>
118+
<meta charset="UTF-8">
119+
<meta http-equiv="Content-Security-Policy"
120+
content="default-src 'none';
121+
frame-src ${allowedOrigin};
122+
script-src 'nonce-${nonce}';
123+
style-src 'unsafe-inline';">
124+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
125+
<title>Coder Chat</title>
126+
<style>
127+
html, body {
128+
margin: 0; padding: 0;
129+
width: 100%; height: 100%;
130+
overflow: hidden;
131+
background: var(--vscode-editor-background, #1e1e1e);
132+
}
133+
iframe { border: none; width: 100%; height: 100%; }
134+
#status {
135+
color: var(--vscode-foreground, #ccc);
136+
font-family: var(--vscode-font-family, sans-serif);
137+
font-size: 13px; padding: 16px; text-align: center;
138+
}
139+
</style>
140+
</head>
141+
<body>
142+
<div id="status">Loading chat…</div>
143+
<iframe id="chat-frame" src="${embedUrl}" allow="clipboard-write"
144+
style="display:none;"></iframe>
145+
<script nonce="${nonce}">
146+
(function () {
147+
const vscode = acquireVsCodeApi();
148+
const iframe = document.getElementById('chat-frame');
149+
const status = document.getElementById('status');
150+
151+
iframe.addEventListener('load', () => {
152+
iframe.style.display = 'block';
153+
status.style.display = 'none';
154+
});
155+
156+
window.addEventListener('message', (event) => {
157+
const data = event.data;
158+
if (!data || typeof data !== 'object') return;
159+
160+
if (event.source === iframe.contentWindow) {
161+
if (data.type === 'coder:vscode-ready') {
162+
status.textContent = 'Authenticating…';
163+
vscode.postMessage({ type: 'coder:vscode-ready' });
164+
}
165+
return;
166+
}
167+
168+
if (data.type === 'coder:auth-bootstrap-token') {
169+
status.textContent = 'Signing in…';
170+
iframe.contentWindow.postMessage({
171+
type: 'coder:vscode-auth-bootstrap',
172+
payload: { token: data.token },
173+
}, '${allowedOrigin}');
174+
}
175+
});
176+
})();
177+
</script>
178+
</body>
179+
</html>`;
180+
}
181+
182+
private getNoAgentHtml(): string {
183+
return /* html */ `<!DOCTYPE html>
184+
<html lang="en"><head><meta charset="UTF-8">
185+
<style>body{display:flex;align-items:center;justify-content:center;
186+
height:100vh;margin:0;padding:16px;box-sizing:border-box;
187+
font-family:var(--vscode-font-family);color:var(--vscode-foreground);
188+
text-align:center;}</style></head>
189+
<body><p>No active chat session. Open a chat from the Agents tab on your Coder deployment.</p></body></html>`;
190+
}
191+
192+
dispose(): void {
193+
for (const d of this.disposables) {
194+
d.dispose();
195+
}
196+
this.disposables = [];
197+
}
198+
}

0 commit comments

Comments
 (0)