Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,21 @@ test/
└── mocks/ # Shared test mocks
```

## Webviews

When adding or modifying a panel, follow `packages/webview-shared/README.md`.
It is the single source of truth for the IPC contract, exhaustive handler
maps, and the visibility/theme re-send guarantee.

Non-negotiables:

- Never hand-roll `window.addEventListener("message", ...)` or
`postMessage({ method, params })`. Use `onNotification` / `sendCommand`
(vanilla) or `useIpc` (React) from `@repo/webview-shared`.
- Extension panels must call **both** `buildCommandHandlers` and
`buildRequestHandlers` (empty `{}` is fine). This gives a compile error
when anyone adds an action to the API without a matching handler.

## Code Style

- TypeScript with strict typing
Expand Down
39 changes: 11 additions & 28 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,44 +74,27 @@ that are close to shutting down.

## Webviews

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

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

```text
packages/
├── webview-shared/ # Shared types, React hooks, and Vite config
│ └── extension.d.ts # Types exposed to extension (excludes React)
└── tasks/ # Example webview (copy this for new webviews)

src/webviews/
├── util.ts # getWebviewHtml() helper
└── tasks/ # Extension-side provider for tasks panel
```

Key patterns:
Existing webviews as references:

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

### Development

```bash
pnpm watch # Rebuild extension and webviews on changes
```

Press F5 to launch the Extension Development Host. Use "Developer: Reload Webviews"
to see webview changes.

### Adding a New Webview

1. Copy `packages/tasks` to `packages/<name>` and update the package name
2. Create a provider in `src/webviews/<name>/` (see `TasksPanel.ts` for reference)
3. Register the view in `package.json` under `contributes.views`
4. Register the provider in `src/extension.ts`
Press F5 to launch the Extension Development Host. Use "Developer: Reload
Webviews" to see webview changes.

## Testing

Expand Down
20 changes: 20 additions & 0 deletions packages/shared/src/chat/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineCommand, defineNotification } from "../ipc/protocol";

/** The chat webview embeds an iframe that speaks to the Coder server. */
export const ChatApi = {
/** Iframe reports it needs the session token. */
vscodeReady: defineCommand("coder:vscode-ready"),
/** Iframe reports the chat UI has rendered. */
chatReady: defineCommand("coder:chat-ready"),
/** Iframe requests an external navigation; same-origin only. */
navigate: defineCommand<{ url: string }>("coder:navigate"),

/** Push the current theme into the iframe. */
setTheme: defineNotification<{ theme: "light" | "dark" }>("coder:set-theme"),
/** Push the session token to bootstrap iframe auth. */
authBootstrapToken: defineNotification<{ token: string }>(
"coder:auth-bootstrap-token",
),
/** Signal that auth could not be obtained. */
authError: defineNotification<{ error: string }>("coder:auth-error"),
} as const;
3 changes: 3 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ export {
type SpeedtestInterval,
type SpeedtestResult,
} from "./speedtest/api";

// Chat API
export { ChatApi } from "./chat/api";
6 changes: 3 additions & 3 deletions packages/speedtest/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SpeedtestApi, type SpeedtestResult, toError } from "@repo/shared";
import { postMessage, subscribeNotification } from "@repo/webview-shared";
import { onNotification, sendCommand } from "@repo/webview-shared";

import { renderLineChart } from "./chart";
import {
Expand All @@ -18,11 +18,11 @@ const TOOLTIP_GAP_PX = 32;
let cleanup: (() => void) | undefined;

function main(): void {
subscribeNotification(SpeedtestApi.data, ({ workspaceName, result }) => {
onNotification(SpeedtestApi.data, ({ workspaceName, result }) => {
try {
cleanup?.();
cleanup = renderPage(result, workspaceName, () =>
postMessage({ method: SpeedtestApi.viewJson.method }),
sendCommand(SpeedtestApi.viewJson),
);
} catch (err) {
showError(`Failed to render speedtest: ${toError(err).message}`);
Expand Down
137 changes: 137 additions & 0 deletions packages/webview-shared/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Webview IPC

Shared primitives for typed, reliable messaging between the extension host
and VS Code webviews. Both sides use the **same `Api` definition object** so
wire formats can't drift and method-name typos are caught at compile time.

## Message kinds

```ts
// packages/shared/src/ipc/protocol.ts
defineNotification<D>(method); // extension to webview, fire-and-forget
defineCommand<P>(method); // webview to extension, fire-and-forget
defineRequest<P, R>(method); // webview to extension, awaits response
```

Define all three together in one `Api` const so both sides import the exact
same method strings:

```ts
// packages/shared/src/myfeature/api.ts
export const MyFeatureApi = {
data: defineNotification<MyFeatureData>("myfeature/data"),
doThing: defineCommand<{ id: string }>("myfeature/doThing"),
getThings: defineRequest<void, Thing[]>("myfeature/getThings"),
} as const;
```

## Extension side (`src/webviews/...`)

### Sending notifications

```ts
import { notifyWebview } from "../util";

notifyWebview(panel.webview, MyFeatureApi.data, payload);
```

### Handling incoming messages (exhaustiveness)

Every panel **must** build handler maps with `buildCommandHandlers` and
`buildRequestHandlers`, even if it has zero requests today. Both builders
are mapped over the `Api` definition, so adding a new `defineCommand` or
`defineRequest` entry **produces a compile error** at the panel that forgot
to wire a handler. This makes it impossible to ship an API surface that
the extension silently drops.

```ts
import { buildCommandHandlers, buildRequestHandlers } from "@repo/shared";
import {
dispatchCommand,
dispatchRequest,
isIpcCommand,
isIpcRequest,
} from "../util";

const commandHandlers = buildCommandHandlers(MyFeatureApi, {
doThing: async (p) => { ... },
});
// Empty is fine; it still enforces future additions.
const requestHandlers = buildRequestHandlers(MyFeatureApi, {
getThings: async () => this.fetchThings(),
});

panel.webview.onDidReceiveMessage((message: unknown) => {
if (isIpcRequest(message)) {
void dispatchRequest(message, requestHandlers, panel.webview, {
logger,
showErrorToUser: (m) => USER_ACTION_METHODS.has(m),
});
} else if (isIpcCommand(message)) {
void dispatchCommand(message, commandHandlers, { logger });
}
});
```

**Error semantics** (built into `dispatchCommand` / `dispatchRequest`):

| | Logs | Response sent? | `showErrorMessage` default |
| ------------- | ------------- | ----------------------------- | -------------------------- |
| Command fails | `logger.warn` | n/a | **yes** (user action) |
| Request fails | `logger.warn` | `success: false` with `error` | **no** (often background) |

Pass `showErrorToUser: (method) => …` to override per-method.

## Webview side, vanilla (`@repo/webview-shared`)

```ts
import { MyFeatureApi } from "@repo/shared";
import { onNotification, sendCommand } from "@repo/webview-shared";

// Subscribe to pushes.
const unsubscribe = onNotification(MyFeatureApi.data, (payload) => {
render(payload);
});

// Fire a command.
sendCommand(MyFeatureApi.doThing, { id: "42" });
```

`onNotification` returns an unsubscribe function; call it on cleanup.

## Webview side, React

Use `useIpc` (`packages/webview-shared/src/react/useIpc`). Same semantics
plus request/response correlation with timeout and UUID bookkeeping.

## The "no dropped events" guarantee

Webview contexts are **destroyed when hidden** unless
`retainContextWhenHidden` is set (costly; avoid). This means the webview's
in-memory state, event listeners, and canvas pixels are lost whenever the
user switches tabs.

Every webview that pushes state from the extension must therefore **re-send
on these signals**, so the revived webview isn't left empty or stale:

1. **Visibility change**: `panel.onDidChangeViewState(() => panel.visible && resend())`.
The webview was just re-created from HTML, so no in-memory state remains.
2. **Color theme change**: `vscode.window.onDidChangeActiveColorTheme(() => panel.visible && resend())`.
DOM elements update via CSS vars, but canvas and SVG drawn imperatively
do not: pixels are baked in. The webview must redraw against the new
theme.

The `onWhileVisible(panel, event, handler)` helper in `src/webviews/util.ts`
wraps both cases. Both disposables must be collected and disposed in
`panel.onDidDispose` so they don't leak when the user closes the tab.

See `src/webviews/speedtest/speedtestPanel.ts` for a minimal reference.

## Checklist for a new webview

1. Define the API in `packages/shared/src/<feature>/api.ts` and export from `packages/shared/src/index.ts`.
2. Extension side: `buildCommandHandlers` **and** `buildRequestHandlers` (empty `{}` is fine; both are needed for exhaustiveness).
3. Extension side: dispatch through `isIpcRequest` to `dispatchRequest` and `isIpcCommand` to `dispatchCommand`, both with a logger.
4. Extension side: use `onWhileVisible` for `onDidChangeViewState` and `onDidChangeActiveColorTheme`, dispose in `onDidDispose`.
5. Webview side: use `onNotification` / `sendCommand` (vanilla) or `useIpc` (React). Never hand-roll `window.addEventListener("message", ...)`.
6. Tests: verify the panel posts the expected payload shape, re-sends on visibility and theme, and handles incoming commands.
4 changes: 2 additions & 2 deletions packages/webview-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ export interface WebviewMessage {
// VS Code state API
export { getState, setState, postMessage } from "./api";

// Notification subscription for non-React webviews
export { subscribeNotification } from "./notifications";
// Typed IPC helpers for vanilla webviews
export { sendCommand, onNotification } from "./ipc";
43 changes: 43 additions & 0 deletions packages/webview-shared/src/ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Typed IPC helpers for vanilla-TS webviews. React webviews should use
* `useIpc` (./react/useIpc), which adds request/response correlation.
*/

import { postMessage } from "./api";

import type { CommandDef, NotificationDef } from "@repo/shared";

/** Send a fire-and-forget command to the extension. */
export function sendCommand<P>(
def: CommandDef<P>,
...args: P extends void ? [] : [params: P]
): void {
postMessage({
method: def.method,
params: args[0],
});
}

/**
* Subscribe to a typed notification from the extension. Returns an
* unsubscribe function; call it on cleanup. Multiple subscribers are
* invoked in registration order.
*/
export function onNotification<D>(
def: NotificationDef<D>,
callback: (data: D) => void,
): () => void {
const handler = (event: MessageEvent<unknown>) => {
const msg = event.data;
if (
typeof msg !== "object" ||
msg === null ||
(msg as { type?: unknown }).type !== def.method
) {
return;
}
callback((msg as { data: D }).data);
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}
20 changes: 0 additions & 20 deletions packages/webview-shared/src/notifications.ts

This file was deleted.

6 changes: 3 additions & 3 deletions packages/webview-shared/src/react/useIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { useEffect, useRef } from "react";

import { postMessage } from "../api";
import { subscribeNotification } from "../notifications";
import { onNotification as subscribe } from "../ipc";

import type {
CommandDef,
Expand Down Expand Up @@ -61,7 +61,7 @@ export function useIpc(options: UseIpcOptions = {}) {
}, []);

// Request/response correlation lives here. Notifications are routed via
// the shared subscribeNotification helper (see onNotification below).
// the shared onNotification helper (see the method below).
useEffect(() => {
const handler = (event: MessageEvent) => {
const msg = event.data as IpcResponse | undefined;
Expand Down Expand Up @@ -143,7 +143,7 @@ export function useIpc(options: UseIpcOptions = {}) {
definition: NotificationDef<D>,
callback: (data: D) => void,
): () => void {
return subscribeNotification(definition, callback);
return subscribe(definition, callback);
}

return { request, command, onNotification };
Expand Down
Loading