Skip to content

Commit abfbedc

Browse files
committed
refactor: migrate chat panel to shared IPC helpers
Define ChatApi in shared and replace the ad-hoc {type, ...} message handling with buildCommandHandlers / buildRequestHandlers plus dispatchCommand / dispatchRequest. Outgoing notifications route through a private notify() wrapper, mirroring tasksPanelProvider. The iframe shim in the inline HTML keeps its own {type, payload} contract with the Coder server, but its shim-to-extension side now speaks the IPC wire format. Split the handler into handleFromIframe / handleFromExtension + a toIframe helper + a showRetry builder so the dispatch reads straight through. Tests updated to the new wire format.
1 parent 81193c3 commit abfbedc

5 files changed

Lines changed: 160 additions & 101 deletions

File tree

packages/shared/src/chat/api.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { defineCommand, defineNotification } from "../ipc/protocol";
2+
3+
/** The chat webview embeds an iframe that speaks to the Coder server. */
4+
export const ChatApi = {
5+
/** Iframe reports it needs the session token. */
6+
vscodeReady: defineCommand("coder:vscode-ready"),
7+
/** Iframe reports the chat UI has rendered. */
8+
chatReady: defineCommand("coder:chat-ready"),
9+
/** Iframe requests an external navigation; same-origin only. */
10+
navigate: defineCommand<{ url: string }>("coder:navigate"),
11+
12+
/** Push the current theme into the iframe. */
13+
setTheme: defineNotification<{ theme: "light" | "dark" }>("coder:set-theme"),
14+
/** Push the session token to bootstrap iframe auth. */
15+
authBootstrapToken: defineNotification<{ token: string }>(
16+
"coder:auth-bootstrap-token",
17+
),
18+
/** Signal that auth could not be obtained. */
19+
authError: defineNotification<{ error: string }>("coder:auth-error"),
20+
} as const;

packages/shared/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@ export {
1616
type SpeedtestInterval,
1717
type SpeedtestResult,
1818
} from "./speedtest/api";
19+
20+
// Chat API
21+
export { ChatApi } from "./chat/api";

src/webviews/chat/chatPanelProvider.ts

Lines changed: 125 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,36 @@
11
import * as vscode from "vscode";
22

3+
import {
4+
buildCommandHandlers,
5+
buildRequestHandlers,
6+
ChatApi,
7+
type NotificationDef,
8+
} from "@repo/shared";
9+
310
import { type CoderApi } from "../../api/coderApi";
411
import { 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
*/
2135
export 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
})();

src/webviews/speedtest/speedtestPanel.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ export function showSpeedtestChart(
5555
// canvas pixels bake in theme colors. Re-send on visibility and theme
5656
// changes so the chart rehydrates.
5757
const payload: SpeedtestData = { workspaceName, result };
58-
const sendData = () => notifyWebview(panel.webview, SpeedtestApi.data, payload);
58+
const sendData = () =>
59+
notifyWebview(panel.webview, SpeedtestApi.data, payload);
5960
sendData();
6061

6162
// Both builders emit a compile error if any command or request in the

test/unit/webviews/chat/chatPanelProvider.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,11 @@ describe("ChatPanelProvider", () => {
9292
windowMock.__setActiveColorThemeKind(kind);
9393
const { sendFromWebview, postMessage } = createHarness();
9494

95-
sendFromWebview({ type: "coder:chat-ready" });
95+
sendFromWebview({ method: "coder:chat-ready" });
9696

9797
expect(findPostedMessage(postMessage, "coder:set-theme")).toEqual({
9898
type: "coder:set-theme",
99-
theme: expected,
99+
data: { theme: expected },
100100
});
101101
});
102102

@@ -108,7 +108,7 @@ describe("ChatPanelProvider", () => {
108108

109109
expect(postMessage).toHaveBeenCalledWith({
110110
type: "coder:set-theme",
111-
theme: "light",
111+
data: { theme: "light" },
112112
});
113113
});
114114
});
@@ -117,13 +117,13 @@ describe("ChatPanelProvider", () => {
117117
it("sends auth token on coder:vscode-ready", () => {
118118
const { sendFromWebview, postMessage } = createHarness();
119119

120-
sendFromWebview({ type: "coder:vscode-ready" });
120+
sendFromWebview({ method: "coder:vscode-ready" });
121121

122122
expect(
123123
findPostedMessage(postMessage, "coder:auth-bootstrap-token"),
124124
).toEqual({
125125
type: "coder:auth-bootstrap-token",
126-
token: "test-token",
126+
data: { token: "test-token" },
127127
});
128128
});
129129
});
@@ -133,8 +133,8 @@ describe("ChatPanelProvider", () => {
133133
const { sendFromWebview } = createHarness();
134134

135135
sendFromWebview({
136-
type: "coder:navigate",
137-
payload: { url: "/templates" },
136+
method: "coder:navigate",
137+
params: { url: "/templates" },
138138
});
139139

140140
expect(vscode.env.openExternal).toHaveBeenCalledWith(
@@ -145,7 +145,7 @@ describe("ChatPanelProvider", () => {
145145
it("ignores navigate without url payload", () => {
146146
const { sendFromWebview } = createHarness();
147147

148-
sendFromWebview({ type: "coder:navigate" });
148+
sendFromWebview({ method: "coder:navigate", params: {} });
149149

150150
expect(vscode.env.openExternal).not.toHaveBeenCalled();
151151
});
@@ -154,8 +154,8 @@ describe("ChatPanelProvider", () => {
154154
const { sendFromWebview } = createHarness();
155155

156156
sendFromWebview({
157-
type: "coder:navigate",
158-
payload: { url: "https://evil.com/steal" },
157+
method: "coder:navigate",
158+
params: { url: "https://evil.com/steal" },
159159
});
160160

161161
expect(vscode.env.openExternal).not.toHaveBeenCalled();

0 commit comments

Comments
 (0)