11import * as vscode from "vscode" ;
22
3+ import {
4+ buildCommandHandlers ,
5+ buildRequestHandlers ,
6+ ChatApi ,
7+ type NotificationDef ,
8+ } from "@repo/shared" ;
9+
310import { type CoderApi } from "../../api/coderApi" ;
411import { type Logger } from "../../logging/logger" ;
5- import { getNonce } from "../util" ;
12+ import {
13+ dispatchCommand ,
14+ dispatchRequest ,
15+ getNonce ,
16+ isIpcCommand ,
17+ isIpcRequest ,
18+ notifyWebview ,
19+ } from "../util" ;
620
721/**
822 * Provides a webview that embeds the Coder agent chat UI.
923 * Authentication flows through postMessage:
1024 *
1125 * 1. The iframe loads /agents/{id}/embed on the Coder server.
12- * 2. The embed page detects the user is signed out and sends
26+ * 2. The embed page detects the user is signed out and posts
1327 * { type: "coder:vscode-ready" } to window.parent.
14- * 3. Our webview relays this to the extension host.
15- * 4. The extension host replies with the session token .
16- * 5 . The webview forwards { type: "coder:vscode-auth-bootstrap" }
17- * with the token back into the iframe .
18- * 6 . The embed page calls API.setSessionToken(token), re-fetches
19- * the authenticated user, and renders the chat UI .
28+ * 3. Our shim relays that to the extension as a ChatApi.vscodeReady
29+ * command .
30+ * 4 . The extension pushes the session token back with
31+ * ChatApi.authBootstrapToken .
32+ * 5 . The shim forwards it into the iframe, which calls
33+ * API.setSessionToken and re-fetches the user .
2034 */
2135export class ChatPanelProvider
2236 implements vscode . WebviewViewProvider , vscode . Disposable
@@ -28,6 +42,13 @@ export class ChatPanelProvider
2842 private chatId : string | undefined ;
2943 private authRetryTimer : ReturnType < typeof setTimeout > | undefined ;
3044
45+ private readonly commandHandlers = buildCommandHandlers ( ChatApi , {
46+ vscodeReady : ( ) => this . sendAuthToken ( ) ,
47+ chatReady : ( ) => this . sendTheme ( ) ,
48+ navigate : ( { url } ) => this . handleNavigate ( url ) ,
49+ } ) ;
50+ private readonly requestHandlers = buildRequestHandlers ( ChatApi , { } ) ;
51+
3152 constructor (
3253 private readonly client : Pick < CoderApi , "getHost" | "getSessionToken" > ,
3354 private readonly logger : Logger ,
@@ -41,11 +62,17 @@ export class ChatPanelProvider
4162 : "dark" ;
4263 }
4364
65+ private notify < D > (
66+ def : NotificationDef < D > ,
67+ ...args : D extends void ? [ ] : [ data : D ]
68+ ) : void {
69+ if ( this . view ) {
70+ notifyWebview ( this . view . webview , def , ...args ) ;
71+ }
72+ }
73+
4474 private sendTheme ( ) : void {
45- this . view ?. webview . postMessage ( {
46- type : "coder:set-theme" ,
47- theme : this . getTheme ( ) ,
48- } ) ;
75+ this . notify ( ChatApi . setTheme , { theme : this . getTheme ( ) } ) ;
4976 }
5077
5178 /**
@@ -75,8 +102,20 @@ export class ChatPanelProvider
75102 this . view = webviewView ;
76103 webviewView . webview . options = { enableScripts : true } ;
77104 this . disposables . push (
78- webviewView . webview . onDidReceiveMessage ( ( msg : unknown ) => {
79- this . handleMessage ( msg ) ;
105+ webviewView . webview . onDidReceiveMessage ( ( message : unknown ) => {
106+ if ( isIpcRequest ( message ) ) {
107+ void dispatchRequest (
108+ message ,
109+ this . requestHandlers ,
110+ webviewView . webview ,
111+ { logger : this . logger } ,
112+ ) ;
113+ } else if ( isIpcCommand ( message ) ) {
114+ void dispatchCommand ( message , this . commandHandlers , {
115+ logger : this . logger ,
116+ showErrorToUser : ( ) => false ,
117+ } ) ;
118+ }
80119 } ) ,
81120 vscode . window . onDidChangeActiveColorTheme ( ( ) => {
82121 this . sendTheme ( ) ;
@@ -114,38 +153,19 @@ export class ChatPanelProvider
114153 webview . html = this . getIframeHtml ( embedUrl , coderUrl ) ;
115154 }
116155
117- private handleMessage ( message : unknown ) : void {
118- if ( typeof message !== "object" || message === null ) {
156+ private handleNavigate ( url : string ) : void {
157+ const coderUrl = this . client . getHost ( ) ;
158+ if ( ! url || ! coderUrl ) {
119159 return ;
120160 }
121- const msg = message as { type ?: string ; payload ?: { url ?: string } } ;
122- switch ( msg . type ) {
123- case "coder:vscode-ready" :
124- this . sendAuthToken ( ) ;
125- break ;
126- case "coder:chat-ready" :
127- this . sendTheme ( ) ;
128- break ;
129- case "coder:navigate" : {
130- const url = msg . payload ?. url ;
131- const coderUrl = this . client . getHost ( ) ;
132- if ( url && coderUrl ) {
133- try {
134- const resolved = new URL ( url , coderUrl ) ;
135- const expected = new URL ( coderUrl ) ;
136- if ( resolved . origin === expected . origin ) {
137- void vscode . env . openExternal (
138- vscode . Uri . parse ( resolved . toString ( ) ) ,
139- ) ;
140- }
141- } catch {
142- this . logger . warn ( `Chat: invalid navigate URL: ${ url } ` ) ;
143- }
144- }
145- break ;
161+ try {
162+ const resolved = new URL ( url , coderUrl ) ;
163+ const expected = new URL ( coderUrl ) ;
164+ if ( resolved . origin === expected . origin ) {
165+ void vscode . env . openExternal ( vscode . Uri . parse ( resolved . toString ( ) ) ) ;
146166 }
147- default :
148- break ;
167+ } catch {
168+ this . logger . warn ( `Chat: invalid navigate URL: ${ url } ` ) ;
149169 }
150170 }
151171
@@ -178,17 +198,13 @@ export class ChatPanelProvider
178198 "Chat iframe requested auth but no session token available " +
179199 "after all retries" ,
180200 ) ;
181- this . view ?. webview . postMessage ( {
182- type : "coder:auth-error" ,
201+ this . notify ( ChatApi . authError , {
183202 error : "No session token available. Please sign in and retry." ,
184203 } ) ;
185204 return ;
186205 }
187206 this . logger . info ( "Chat: forwarding token to iframe" ) ;
188- this . view ?. webview . postMessage ( {
189- type : "coder:auth-bootstrap-token" ,
190- token,
191- } ) ;
207+ this . notify ( ChatApi . authBootstrapToken , { token } ) ;
192208 }
193209
194210 private getIframeHtml ( embedUrl : string , allowedOrigin : string ) : string {
@@ -246,53 +262,72 @@ export class ChatPanelProvider
246262 status.style.display = 'none';
247263 });
248264
249- window.addEventListener('message', (event) => {
250- const data = event.data;
251- if (!data || typeof data !== 'object') return;
265+ // Shim sits between two wire formats. The iframe speaks
266+ // { type, payload } (a contract owned by the Coder server).
267+ // The extension speaks the IPC protocol: commands are
268+ // { method, params } and notifications are { type, data }.
269+ // See packages/webview-shared/README.md.
270+ const toIframe = (type, payload) => {
271+ iframe.contentWindow.postMessage({ type, payload }, '${ allowedOrigin } ');
272+ };
252273
253- if (event.source === iframe.contentWindow) {
254- if (data.type === 'coder:vscode-ready') {
255- status.textContent = 'Authenticating…';
256- vscode.postMessage({ type: 'coder:vscode-ready' });
257- }
258- if (data.type === 'coder:chat-ready') {
259- vscode.postMessage({ type: 'coder:chat-ready' });
260- }
261- if (data.type === 'coder:navigate') {
262- vscode.postMessage(data);
263- }
264- return;
265- }
274+ const showRetry = (error) => {
275+ status.textContent = '';
276+ status.appendChild(document.createTextNode(error || 'Authentication failed.'));
277+ const btn = document.createElement('button');
278+ btn.id = 'retry-btn';
279+ btn.textContent = 'Retry';
280+ btn.addEventListener('click', () => {
281+ status.textContent = 'Authenticating…';
282+ vscode.postMessage({ method: 'coder:vscode-ready' });
283+ });
284+ status.appendChild(document.createElement('br'));
285+ status.appendChild(btn);
286+ status.style.display = 'block';
287+ iframe.style.display = 'none';
288+ };
266289
267- if (data.type === 'coder:auth-bootstrap-token') {
268- status.textContent = 'Signing in…';
269- iframe.contentWindow.postMessage({
270- type: 'coder:vscode-auth-bootstrap',
271- payload: { token: data.token },
272- }, '${ allowedOrigin } ');
290+ const handleFromIframe = (msg) => {
291+ switch (msg.type) {
292+ case 'coder:vscode-ready':
293+ status.textContent = 'Authenticating…';
294+ vscode.postMessage({ method: 'coder:vscode-ready' });
295+ return;
296+ case 'coder:chat-ready':
297+ vscode.postMessage({ method: 'coder:chat-ready' });
298+ return;
299+ case 'coder:navigate':
300+ vscode.postMessage({
301+ method: 'coder:navigate',
302+ params: { url: msg.payload?.url },
303+ });
304+ return;
273305 }
306+ };
274307
275- if (data.type === 'coder:set-theme') {
276- iframe.contentWindow.postMessage({
277- type: 'coder:set-theme',
278- payload: { theme: data.theme },
279- }, '${ allowedOrigin } ');
308+ const handleFromExtension = (msg) => {
309+ const data = msg.data || {};
310+ switch (msg.type) {
311+ case 'coder:auth-bootstrap-token':
312+ status.textContent = 'Signing in…';
313+ toIframe('coder:vscode-auth-bootstrap', { token: data.token });
314+ return;
315+ case 'coder:set-theme':
316+ toIframe('coder:set-theme', { theme: data.theme });
317+ return;
318+ case 'coder:auth-error':
319+ showRetry(data.error);
320+ return;
280321 }
322+ };
281323
282- if (data.type === 'coder:auth-error') {
283- status.textContent = '';
284- status.appendChild(document.createTextNode(data.error || 'Authentication failed.'));
285- const btn = document.createElement('button');
286- btn.id = 'retry-btn';
287- btn.textContent = 'Retry';
288- btn.addEventListener('click', () => {
289- status.textContent = 'Authenticating…';
290- vscode.postMessage({ type: 'coder:vscode-ready' });
291- });
292- status.appendChild(document.createElement('br'));
293- status.appendChild(btn);
294- status.style.display = 'block';
295- iframe.style.display = 'none';
324+ window.addEventListener('message', (event) => {
325+ const msg = event.data;
326+ if (!msg || typeof msg !== 'object') return;
327+ if (event.source === iframe.contentWindow) {
328+ handleFromIframe(msg);
329+ } else {
330+ handleFromExtension(msg);
296331 }
297332 });
298333 })();
0 commit comments