Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
23 changes: 22 additions & 1 deletion 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,8 +223,18 @@ 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),
Comment thread
ThomasK33 marked this conversation as resolved.
vscode.commands.registerCommand(
"coder.login",
commands.login.bind(commands),
Expand Down Expand Up @@ -291,6 +302,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {

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.
Expand Down
42 changes: 40 additions & 2 deletions src/uri/uriHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,22 @@ 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<void>;

const routes: Readonly<Record<string, UriRouteHandler>> = {
"/open": handleOpen,
"/openDevContainer": handleOpenDevContainer,
"/openChat": handleOpenChat,
Comment thread
ThomasK33 marked this conversation as resolved.
Outdated
[CALLBACK_PATH]: handleOAuthCallback,
};

Expand All @@ -31,13 +34,20 @@ 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}`);
Expand All @@ -56,6 +66,7 @@ async function routeUri(
serviceContainer: ServiceContainer,
deploymentManager: DeploymentManager,
commands: Commands,
chatPanelProvider: ChatPanelProvider,
): Promise<void> {
const handler = routes[uri.path];
if (!handler) {
Expand All @@ -67,6 +78,7 @@ async function routeUri(
serviceContainer,
deploymentManager,
commands,
chatPanelProvider,
});
}

Expand All @@ -79,7 +91,13 @@ function getRequiredParam(params: URLSearchParams, name: string): string {
}

async function handleOpen(ctx: UriRouteContext): Promise<void> {
const { params, serviceContainer, deploymentManager, commands } = ctx;
const {
params,
serviceContainer,
deploymentManager,
commands,
chatPanelProvider,
} = ctx;

const owner = getRequiredParam(params, "owner");
const workspace = getRequiredParam(params, "workspace");
Expand All @@ -89,6 +107,11 @@ async function handleOpen(ctx: UriRouteContext): Promise<void> {
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(
Expand All @@ -98,6 +121,10 @@ async function handleOpen(ctx: UriRouteContext): Promise<void> {
folder ?? undefined,
openRecent,
);

if (agentId) {
chatPanelProvider.openChat(agentId);
}
}

async function handleOpenDevContainer(ctx: UriRouteContext): Promise<void> {
Expand Down Expand Up @@ -180,6 +207,17 @@ async function setupDeployment(
});
}

async function handleOpenChat(ctx: UriRouteContext): Promise<void> {
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<void> {
const { params, serviceContainer } = ctx;
const logger = serviceContainer.getLogger();
Expand Down
196 changes: 196 additions & 0 deletions src/webviews/chat/chatPanelProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
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.disposeInternals());
}

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

private renderView(): void {
const webview = this.view!.webview;
Comment thread
ThomasK33 marked this conversation as resolved.
Outdated

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

const coderUrl = this.client.getHost();
if (!coderUrl) {
webview.html = this.getNoAgentHtml();
return;
}
Comment thread
EhabY marked this conversation as resolved.
Outdated

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,
});
}
Comment thread
EhabY marked this conversation as resolved.
}

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

return /* html */ `<!DOCTYPE html>
Comment thread
EhabY marked this conversation as resolved.
<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 thread
EhabY marked this conversation as resolved.

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;
}
Comment thread
EhabY marked this conversation as resolved.

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>`;
}

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

dispose(): void {
this.disposeInternals();
}
Comment thread
ThomasK33 marked this conversation as resolved.
Outdated
}
Loading