Skip to content

Commit 20072d8

Browse files
authored
refactor: shared webview IPC helpers with enforced exhaustiveness (#911)
Both sides of every webview now import the same `Api` definition, so wire formats can't drift and method typos fail at compile time. - Extension dispatch helpers (`dispatchCommand`, `dispatchRequest`, `notifyWebview`, `onWhileVisible`, `isIpc*` predicates) live in `src/webviews/dispatch.ts`; HTML scaffolding (`getWebviewHtml`, `getWebviewAssetUris`, `buildWebviewCsp`) in `src/webviews/html.ts`. - Webview helpers `sendCommand`, `subscribeNotifications`, and `buildNotificationRouter` in `@repo/webview-shared`. React webviews keep `useIpc`. - Compile-time exhaustiveness fails the build in three places: `buildCommandHandlers` / `buildRequestHandlers` on the extension, `subscribeNotifications` on vanilla webviews, and the generated `apiHook.on<Name>` for React webviews. - Chat panel migrated off its ad-hoc `{type, ...}` protocol onto `ChatApi`. The iframe shim was extracted into a typed bundle at `packages/chat/` with a single source-gated `window.message` listener bridging the iframe's `{type, payload}` contract and `ChatApi`. - `packages/webview-shared/README.md` rewritten as a navigation map with a "Where each handler lives" table and pointers to the canonical reference packages.
1 parent e4a8fed commit 20072d8

33 files changed

Lines changed: 1383 additions & 430 deletions

AGENTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ test/
7979
└── mocks/ # Shared test mocks
8080
```
8181

82+
## Webviews
83+
84+
When adding or modifying a panel, follow `packages/webview-shared/README.md`.
85+
It is the single source of truth for the IPC contract, exhaustive handler
86+
maps, and the visibility/theme re-send guarantee.
87+
88+
Non-negotiables:
89+
90+
- Never hand-roll `window.addEventListener("message", ...)` or
91+
`postMessage({ method, params })`. Use `onNotification` / `sendCommand`
92+
(vanilla) or `useIpc` (React) from `@repo/webview-shared`.
93+
- Extension panels must call **both** `buildCommandHandlers` and
94+
`buildRequestHandlers` (empty `{}` is fine). This gives a compile error
95+
when anyone adds an action to the API without a matching handler.
96+
8297
## Code Style
8398

8499
- TypeScript with strict typing

CONTRIBUTING.md

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -74,44 +74,27 @@ that are close to shutting down.
7474

7575
## Webviews
7676

77-
The extension uses React-based webviews for rich UI panels, built with Vite and
78-
organized as a pnpm workspace in `packages/`.
77+
The extension ships rich UI panels as webviews built with Vite, organized as a
78+
pnpm workspace in `packages/`. The canonical guide for building one covers
79+
the IPC contract, exhaustiveness rules, the "no dropped events" guarantee,
80+
and a new-panel checklist. It lives next to the code:
7981

80-
### Project Structure
82+
**[`packages/webview-shared/README.md`](packages/webview-shared/README.md)**
8183

82-
```text
83-
packages/
84-
├── webview-shared/ # Shared types, React hooks, and Vite config
85-
│ └── extension.d.ts # Types exposed to extension (excludes React)
86-
└── tasks/ # Example webview (copy this for new webviews)
87-
88-
src/webviews/
89-
├── util.ts # getWebviewHtml() helper
90-
└── tasks/ # Extension-side provider for tasks panel
91-
```
92-
93-
Key patterns:
84+
Existing webviews as references:
9485

95-
- **Type sharing**: Extension imports types from `@repo/webview-shared` via path mapping
96-
to `extension.d.ts`. Webviews import directly from `@repo/webview-shared/react`.
97-
- **Message passing**: Use `postMessage()`/`useMessage()` hooks for communication.
98-
- **Lifecycle**: Dispose event listeners properly (see `TasksPanel.ts` for example).
86+
- `packages/tasks` + `src/webviews/tasks/`: React (uses `useIpc`).
87+
- `packages/speedtest` + `src/webviews/speedtest/`: vanilla TS (uses
88+
`onNotification` / `sendCommand`).
9989

10090
### Development
10191

10292
```bash
10393
pnpm watch # Rebuild extension and webviews on changes
10494
```
10595

106-
Press F5 to launch the Extension Development Host. Use "Developer: Reload Webviews"
107-
to see webview changes.
108-
109-
### Adding a New Webview
110-
111-
1. Copy `packages/tasks` to `packages/<name>` and update the package name
112-
2. Create a provider in `src/webviews/<name>/` (see `TasksPanel.ts` for reference)
113-
3. Register the view in `package.json` under `contributes.views`
114-
4. Register the provider in `src/extension.ts`
96+
Press F5 to launch the Extension Development Host. Use "Developer: Reload
97+
Webviews" to see webview changes.
11598

11699
## Testing
117100

packages/chat/package.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@repo/chat",
3+
"version": "1.0.0",
4+
"description": "Coder chat iframe shim webview",
5+
"private": true,
6+
"type": "module",
7+
"scripts": {
8+
"build": "vite build",
9+
"dev": "vite build --watch",
10+
"typecheck": "tsc --noEmit"
11+
},
12+
"dependencies": {
13+
"@repo/shared": "workspace:*",
14+
"@repo/webview-shared": "workspace:*"
15+
},
16+
"devDependencies": {
17+
"@types/vscode-webview": "catalog:",
18+
"typescript": "catalog:",
19+
"vite": "catalog:"
20+
}
21+
}

packages/chat/src/css.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
declare module "*.css";

packages/chat/src/index.css

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
html,
2+
body {
3+
margin: 0;
4+
padding: 0;
5+
width: 100%;
6+
height: 100%;
7+
overflow: hidden;
8+
background: var(--vscode-editor-background, #1e1e1e);
9+
}
10+
11+
iframe {
12+
border: none;
13+
width: 100%;
14+
height: 100%;
15+
}
16+
17+
#status {
18+
color: var(--vscode-foreground, #ccc);
19+
font-family: var(--vscode-font-family, sans-serif);
20+
font-size: 13px;
21+
padding: 16px;
22+
text-align: center;
23+
}
24+
25+
#retry-btn {
26+
margin-top: 12px;
27+
padding: 6px 16px;
28+
background: var(--vscode-button-background, #0e639c);
29+
color: var(--vscode-button-foreground, #fff);
30+
border: none;
31+
border-radius: 2px;
32+
cursor: pointer;
33+
font-family: var(--vscode-font-family, sans-serif);
34+
font-size: 13px;
35+
}
36+
37+
#retry-btn:hover {
38+
background: var(--vscode-button-hoverBackground, #1177bb);
39+
}

packages/chat/src/index.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { ChatApi, type NotificationHandlerMap } from "@repo/shared";
2+
import { buildNotificationRouter, sendCommand } from "@repo/webview-shared";
3+
4+
import "./index.css";
5+
6+
/** Chat shim: source-gated bridge between the iframe `{ type, payload }` protocol and `ChatApi`. */
7+
export function main(): void {
8+
const shim = findShim();
9+
if (!shim) {
10+
return;
11+
}
12+
revealIframeOnLoad(shim);
13+
listenForMessages(shim);
14+
}
15+
16+
interface Shim {
17+
iframe: HTMLIFrameElement;
18+
status: HTMLDivElement;
19+
allowedOrigin: string;
20+
}
21+
22+
interface IframeMessage {
23+
type?: string;
24+
payload?: { url?: string };
25+
}
26+
27+
function findShim(): Shim | null {
28+
const iframe = document.getElementById("chat-frame");
29+
const status = document.getElementById("status");
30+
if (
31+
!(iframe instanceof HTMLIFrameElement) ||
32+
!(status instanceof HTMLDivElement)
33+
) {
34+
return null;
35+
}
36+
return { iframe, status, allowedOrigin: new URL(iframe.src).origin };
37+
}
38+
39+
function revealIframeOnLoad({ iframe, status }: Shim): void {
40+
iframe.addEventListener("load", () => {
41+
iframe.style.display = "block";
42+
status.style.display = "none";
43+
});
44+
}
45+
46+
function listenForMessages(shim: Shim): void {
47+
const route = buildNotificationRouter(
48+
ChatApi,
49+
buildNotificationHandlers(shim),
50+
);
51+
window.addEventListener("message", (event) => {
52+
if (event.source === shim.iframe.contentWindow) {
53+
if (typeof event.data === "object" && event.data !== null) {
54+
handleFromIframe(shim, event.data as IframeMessage);
55+
}
56+
return;
57+
}
58+
route(event.data);
59+
});
60+
}
61+
62+
function handleFromIframe({ status }: Shim, msg: IframeMessage): void {
63+
switch (msg.type) {
64+
case "coder:vscode-ready":
65+
status.textContent = "Authenticating…";
66+
sendCommand(ChatApi.vscodeReady);
67+
return;
68+
case "coder:chat-ready":
69+
sendCommand(ChatApi.chatReady);
70+
return;
71+
case "coder:navigate":
72+
if (msg.payload?.url) {
73+
sendCommand(ChatApi.navigate, { url: msg.payload.url });
74+
}
75+
return;
76+
default:
77+
return;
78+
}
79+
}
80+
81+
// Compile-checked: a new ChatApi notification without a handler fails the build.
82+
function buildNotificationHandlers(
83+
shim: Shim,
84+
): NotificationHandlerMap<typeof ChatApi> {
85+
return {
86+
setTheme: ({ theme }) => postToIframe(shim, "coder:set-theme", { theme }),
87+
authBootstrapToken: ({ token }) => {
88+
shim.status.textContent = "Signing in…";
89+
postToIframe(shim, "coder:vscode-auth-bootstrap", { token });
90+
},
91+
authError: ({ error }) => showRetry(shim, error),
92+
};
93+
}
94+
95+
function postToIframe(
96+
{ iframe, allowedOrigin }: Shim,
97+
type: string,
98+
payload: unknown,
99+
): void {
100+
iframe.contentWindow?.postMessage({ type, payload }, allowedOrigin);
101+
}
102+
103+
function showRetry({ iframe, status }: Shim, error: string): void {
104+
status.textContent = "";
105+
status.appendChild(
106+
document.createTextNode(error || "Authentication failed."),
107+
);
108+
const btn = document.createElement("button");
109+
btn.id = "retry-btn";
110+
btn.textContent = "Retry";
111+
btn.addEventListener("click", () => {
112+
status.textContent = "Authenticating…";
113+
sendCommand(ChatApi.vscodeReady);
114+
});
115+
status.appendChild(document.createElement("br"));
116+
status.appendChild(btn);
117+
status.style.display = "block";
118+
iframe.style.display = "none";
119+
}
120+
121+
main();

packages/chat/tsconfig.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../tsconfig.packages.json",
3+
"compilerOptions": {
4+
"paths": {
5+
"@repo/shared": ["../shared/src"],
6+
"@repo/webview-shared": ["../webview-shared/src"]
7+
}
8+
},
9+
"include": ["src"]
10+
}

packages/chat/vite.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createWebviewConfig } from "../webview-shared/createWebviewConfig";
2+
3+
export default createWebviewConfig("chat", __dirname);

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+
/** Chat webview API. */
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";

0 commit comments

Comments
 (0)