Skip to content
Merged
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
21 changes: 21 additions & 0 deletions packages/chat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@repo/chat",
"version": "1.0.0",
"description": "Coder chat iframe shim webview",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite build --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/shared": "workspace:*",
"@repo/webview-shared": "workspace:*"
},
"devDependencies": {
"@types/vscode-webview": "catalog:",
"typescript": "catalog:",
"vite": "catalog:"
}
}
1 change: 1 addition & 0 deletions packages/chat/src/css.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module "*.css";
39 changes: 39 additions & 0 deletions packages/chat/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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;
}

#retry-btn {
margin-top: 12px;
padding: 6px 16px;
background: var(--vscode-button-background, #0e639c);
color: var(--vscode-button-foreground, #fff);
border: none;
border-radius: 2px;
cursor: pointer;
font-family: var(--vscode-font-family, sans-serif);
font-size: 13px;
}

#retry-btn:hover {
background: var(--vscode-button-hoverBackground, #1177bb);
}
121 changes: 121 additions & 0 deletions packages/chat/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { ChatApi, type NotificationHandlerMap } from "@repo/shared";
import { buildNotificationRouter, sendCommand } from "@repo/webview-shared";

import "./index.css";

/** Chat shim: source-gated bridge between the iframe `{ type, payload }` protocol and `ChatApi`. */
export function main(): void {
const shim = findShim();
if (!shim) {
return;
}
revealIframeOnLoad(shim);
listenForMessages(shim);
}

interface Shim {
iframe: HTMLIFrameElement;
status: HTMLDivElement;
allowedOrigin: string;
}

interface IframeMessage {
type?: string;
payload?: { url?: string };
}

function findShim(): Shim | null {
const iframe = document.getElementById("chat-frame");
const status = document.getElementById("status");
if (
!(iframe instanceof HTMLIFrameElement) ||
!(status instanceof HTMLDivElement)
) {
return null;
}
return { iframe, status, allowedOrigin: new URL(iframe.src).origin };
}

function revealIframeOnLoad({ iframe, status }: Shim): void {
iframe.addEventListener("load", () => {
iframe.style.display = "block";
status.style.display = "none";
});
}

function listenForMessages(shim: Shim): void {
const route = buildNotificationRouter(
ChatApi,
buildNotificationHandlers(shim),
);
window.addEventListener("message", (event) => {
if (event.source === shim.iframe.contentWindow) {
if (typeof event.data === "object" && event.data !== null) {
handleFromIframe(shim, event.data as IframeMessage);
}
return;
}
route(event.data);
});
}

function handleFromIframe({ status }: Shim, msg: IframeMessage): void {
switch (msg.type) {
case "coder:vscode-ready":
status.textContent = "Authenticating…";
sendCommand(ChatApi.vscodeReady);
return;
case "coder:chat-ready":
sendCommand(ChatApi.chatReady);
return;
case "coder:navigate":
if (msg.payload?.url) {
sendCommand(ChatApi.navigate, { url: msg.payload.url });
}
return;
default:
return;
}
}

// Compile-checked: a new ChatApi notification without a handler fails the build.
function buildNotificationHandlers(
shim: Shim,
): NotificationHandlerMap<typeof ChatApi> {
return {
setTheme: ({ theme }) => postToIframe(shim, "coder:set-theme", { theme }),
authBootstrapToken: ({ token }) => {
shim.status.textContent = "Signing in…";
postToIframe(shim, "coder:vscode-auth-bootstrap", { token });
},
authError: ({ error }) => showRetry(shim, error),
};
}

function postToIframe(
{ iframe, allowedOrigin }: Shim,
type: string,
payload: unknown,
): void {
iframe.contentWindow?.postMessage({ type, payload }, allowedOrigin);
}

function showRetry({ iframe, status }: Shim, error: string): void {
status.textContent = "";
status.appendChild(
document.createTextNode(error || "Authentication failed."),
);
const btn = document.createElement("button");
btn.id = "retry-btn";
btn.textContent = "Retry";
btn.addEventListener("click", () => {
status.textContent = "Authenticating…";
sendCommand(ChatApi.vscodeReady);
});
status.appendChild(document.createElement("br"));
status.appendChild(btn);
status.style.display = "block";
iframe.style.display = "none";
}

main();
10 changes: 10 additions & 0 deletions packages/chat/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.packages.json",
"compilerOptions": {
"paths": {
"@repo/shared": ["../shared/src"],
"@repo/webview-shared": ["../webview-shared/src"]
}
},
"include": ["src"]
}
3 changes: 3 additions & 0 deletions packages/chat/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createWebviewConfig } from "../webview-shared/createWebviewConfig";

export default createWebviewConfig("chat", __dirname);
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";

/** Chat webview API. */
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";
9 changes: 9 additions & 0 deletions packages/shared/src/ipc/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ export type CommandHandlerMap<Api> = {
: never;
};

/** Requires a subscriber for every NotificationDef in Api. Compile error if one is missing. */
export type NotificationHandlerMap<Api> = {
[K in keyof Api as Api[K] extends { kind: "notification" }
? K
: never]: Api[K] extends NotificationDef<infer D>
? (data: D) => void
: never;
};

// --- API hook type ---

/** Derives a fully typed hook interface from an API definition object. */
Expand Down
24 changes: 13 additions & 11 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 { sendCommand, subscribeNotifications } from "@repo/webview-shared";

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

function main(): void {
subscribeNotification(SpeedtestApi.data, ({ workspaceId, result }) => {
try {
cleanup?.();
cleanup = renderPage(result, workspaceId, () =>
postMessage({ method: SpeedtestApi.viewJson.method }),
);
} catch (err) {
showError(`Failed to render speedtest: ${toError(err).message}`);
}
subscribeNotifications(SpeedtestApi, {
data: ({ workspaceId, result }) => {
try {
cleanup?.();
cleanup = renderPage(result, workspaceId, () =>
sendCommand(SpeedtestApi.viewJson),
);
} catch (err) {
showError(`Failed to render speedtest: ${toError(err).message}`);
}
},
});
// Signal we're subscribed; the extension waits for this before sending.
postMessage({ method: SpeedtestApi.ready.method });
sendCommand(SpeedtestApi.ready);
}

function renderPage(
Expand Down
Loading