Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@
"title": "Coder Tasks",
"icon": "media/tasks-logo.svg"
}
],
"panel": [
{
"id": "coderChat",
"title": "Coder Chat",
"icon": "media/shorthand-logo.svg"
}
]
},
"views": {
Expand Down Expand Up @@ -224,6 +231,13 @@
"icon": "media/tasks-logo.svg",
"when": "coder.authenticated"
}
],
"coderChat": [
{
"type": "webview",
"id": "coder.chatPanel",
"name": "Coder Chat"
}
]
},
"viewsWelcome": [
Expand Down
24 changes: 24 additions & 0 deletions src/core/mementoManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await this.memento.update("pendingChatAgentId", agentId);
}

/**
* Read and clear the pending chat agent ID. Returns
* undefined if none was stored.
*/
public async getAndClearPendingChatAgentId(): Promise<string | undefined> {
const agentId = this.memento.get<string>("pendingChatAgentId");
if (agentId !== undefined) {
await this.memento.update("pendingChatAgentId", undefined);
}
return agentId;
}
}
21 changes: 21 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Remote } from "./remote/remote";
import { getRemoteSshExtension } from "./remote/sshExtension";
import { registerUriHandler } from "./uri/uriHandler";
import { initVscodeProposed } from "./vscodeProposed";
import { ChatPanelProvider } from "./webviews/chat/chatPanelProvider";
import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider";
import {
WorkspaceProvider,
Expand Down Expand Up @@ -222,6 +223,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
),
);

// 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(
Expand Down Expand Up @@ -315,6 +327,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
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) {
Expand Down
9 changes: 9 additions & 0 deletions src/uri/uriHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ async function handleOpen(ctx: UriRouteContext): Promise<void> {
params.has("openRecent") &&
(!params.get("openRecent") || params.get("openRecent") === "true");

// 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);

await commands.open(
Expand Down
195 changes: 195 additions & 0 deletions src/webviews/chat/chatPanelProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { randomBytes } from "node:crypto";
import * as vscode from "vscode";

import { type CoderApi } from "../../api/coderApi";
import { type Logger } from "../../logging/logger";

/**
* Provides a webview that embeds the Coder agent chat UI.
* Authentication flows through postMessage:
*
* 1. The iframe loads /agents/{id}/embed on the Coder server.
* 2. The embed page detects the user is signed out and sends
* { type: "coder:vscode-ready" } to window.parent.
* 3. Our webview relays this to the extension host.
* 4. The extension host replies with the session token.
* 5. The webview forwards { type: "coder:vscode-auth-bootstrap" }
* with the token back into the iframe.
* 6. The embed page calls API.setSessionToken(token), re-fetches
* the authenticated user, and renders the chat UI.
*/
export class ChatPanelProvider
implements vscode.WebviewViewProvider, vscode.Disposable
{
public static readonly viewType = "coder.chatPanel";

private view?: vscode.WebviewView;
private disposables: vscode.Disposable[] = [];
private 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();
void vscode.commands.executeCommand("coder.chatPanel.focus");
}

resolveWebviewView(
webviewView: vscode.WebviewView,
_context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken,
): void {
this.view = webviewView;
webviewView.webview.options = { enableScripts: true };
this.disposables.push(
webviewView.webview.onDidReceiveMessage((msg: unknown) => {
this.handleMessage(msg);
}),
);
this.renderView();
webviewView.onDidDispose(() => this.dispose());
}

public refresh(): void {
if (!this.view) {
return;
}
this.renderView();
}

private renderView(): void {
if (!this.view) {
throw new Error("renderView called before resolveWebviewView");
}
const webview = this.view.webview;

if (!this.agentId) {
webview.html = this.getNoAgentHtml();
return;
}

const coderUrl = this.client.getHost();
if (!coderUrl) {
webview.html = this.getNoAgentHtml();
return;
}
Comment on lines +73 to +82
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I keep getting the no agent HTML even though I have a chat running, am I doing something wrong 🤔 ?


const embedUrl = `${coderUrl}/agents/${this.agentId}/embed`;
webview.html = this.getIframeHtml(embedUrl, coderUrl);
}

private handleMessage(message: unknown): void {
if (typeof message !== "object" || message === null) {
return;
}
const msg = message as { type?: string };
if (msg.type === "coder:vscode-ready") {
const token = this.client.getSessionToken();
if (!token) {
this.logger.warn(
"Chat iframe requested auth but no session token available",
);
return;
}
this.logger.info("Chat: forwarding token to iframe");
this.view?.webview.postMessage({
type: "coder:auth-bootstrap-token",
token,
});
}
}

private getIframeHtml(embedUrl: string, allowedOrigin: string): string {
const nonce = randomBytes(16).toString("base64");

return /* html */ `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
frame-src ${allowedOrigin};
script-src 'nonce-${nonce}';
style-src 'unsafe-inline';">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coder Chat</title>
<style>
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: var(--vscode-editor-background, #1e1e1e);
}
iframe { border: none; width: 100%; height: 100%; }
#status {
color: var(--vscode-foreground, #ccc);
font-family: var(--vscode-font-family, sans-serif);
font-size: 13px; padding: 16px; text-align: center;
}
</style>
</head>
<body>
<div id="status">Loading chat…</div>
<iframe id="chat-frame" src="${embedUrl}" allow="clipboard-write"
style="display:none;"></iframe>
<script nonce="${nonce}">
(function () {
const vscode = acquireVsCodeApi();
const iframe = document.getElementById('chat-frame');
const status = document.getElementById('status');

iframe.addEventListener('load', () => {
iframe.style.display = 'block';
status.style.display = 'none';
});
Comment on lines +145 to +151
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be made more flexible so that even if messages arrive mid-run it wouldn't cause the status to always be displayed and never cleared (we could have some state tracking in this webview possibly). Anyway if this is unlikely or too much work then we can skip

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's a great idea for a follow-up, but it's not needed for this now.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ties with the auth comment above, so maybe they should be tackled at the same time when needed!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, though I'd argue that it's less a matter of when it's needed and more a matter of if it's needed.


window.addEventListener('message', (event) => {
const data = event.data;
if (!data || typeof data !== 'object') return;

if (event.source === iframe.contentWindow) {
if (data.type === 'coder:vscode-ready') {
status.textContent = 'Authenticating…';
vscode.postMessage({ type: 'coder:vscode-ready' });
}
return;
}

if (data.type === 'coder:auth-bootstrap-token') {
status.textContent = 'Signing in…';
iframe.contentWindow.postMessage({
type: 'coder:vscode-auth-bootstrap',
payload: { token: data.token },
}, '*');
}
});
})();
</script>
</body>
</html>`;
}

private getNoAgentHtml(): string {
return /* html */ `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<style>body{display:flex;align-items:center;justify-content:center;
height:100vh;margin:0;padding:16px;box-sizing:border-box;
font-family:var(--vscode-font-family);color:var(--vscode-foreground);
text-align:center;}</style></head>
<body><p>No active chat session. Open a chat from the Agents tab on your Coder deployment.</p></body></html>`;
}

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