-
Notifications
You must be signed in to change notification settings - Fork 40
feat: embed Coder agent chat in VS Code sidebar panel #844
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5dc2674
157ff8e
aa5ab7f
51316bf
0cc8c02
02d44b2
1e19564
d9c8417
a0b8f59
8cc5b11
7852f5b
5c933a8
444bf4d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| }); | ||
| } | ||
EhabY marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| private getIframeHtml(embedUrl: string, allowedOrigin: string): string { | ||
| const nonce = randomBytes(16).toString("base64"); | ||
|
|
||
| return /* html */ `<!DOCTYPE html> | ||
EhabY marked this conversation as resolved.
Show resolved
Hide 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 on lines
+145
to
+151
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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!
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
EhabY marked this conversation as resolved.
Show resolved
Hide 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>`; | ||
| } | ||
|
|
||
| dispose(): void { | ||
| for (const d of this.disposables) { | ||
| d.dispose(); | ||
| } | ||
| this.disposables = []; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.