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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
- The **Coder: Workspace Build** output channel is no longer created when reconnecting to an
already-running workspace, so the Output panel doesn't pop open empty.

### Changed

- **Coder: Speed Test Workspace** results now render in an interactive throughput chart with
hover tooltips, a summary header, and a real-time progress bar while the CLI runs. A View JSON
action exposes the raw output.

## [v1.14.4-pre](https://github.com/coder/vscode-coder/releases/tag/v1.14.4-pre) 2026-04-20

### Added
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
1 change: 0 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export default defineConfig(
"**/*.d.ts",
"vitest.config.ts",
"**/vite.config*.ts",
"**/createWebviewConfig.ts",
".vscode-test/**",
"test/fixtures/scripts/**",
]),
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;
39 changes: 39 additions & 0 deletions packages/shared/src/error/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/** Convert any thrown value into an Error. Pass `serialize` (e.g. `util.inspect`
* in Node) for richer object formatting; the default `JSON.stringify` will
* throw on circular inputs and fall through to `defaultMsg`. */
export function toError(
value: unknown,
defaultMsg?: string,
serialize: (value: unknown) => string = JSON.stringify,
): Error {
if (value instanceof Error) {
return value;
}

if (typeof value === "string") {
return new Error(value);
}

if (
value !== null &&
typeof value === "object" &&
"message" in value &&
typeof value.message === "string"
) {
const error = new Error(value.message);
if ("name" in value && typeof value.name === "string") {
error.name = value.name;
}
return error;
}

if (value === null || value === undefined) {
return new Error(defaultMsg ?? "Unknown error");
}

try {
return new Error(serialize(value));
} catch {
return new Error(defaultMsg ?? "Non-serializable error object");
}
}
14 changes: 14 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
// IPC protocol types
export * from "./ipc/protocol";

// Error utilities
export { toError } from "./error/utils";

// Tasks types, utilities, and API
export * from "./tasks/types";
export * from "./tasks/utils";
export * from "./tasks/api";

// Speedtest API
export {
SpeedtestApi,
type SpeedtestData,
type SpeedtestInterval,
type SpeedtestResult,
} from "./speedtest/api";

// Chat API
export { ChatApi } from "./chat/api";
22 changes: 15 additions & 7 deletions packages/shared/src/ipc/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,26 +166,34 @@ export function buildApiHook<
api: Api,
ipc: {
request: <P, R>(
def: { method: string; _types?: { params: P; response: R } },
def: RequestDef<P, R>,
...args: P extends void ? [] : [params: P]
) => Promise<R>;
command: <P>(
def: { method: string; _types?: { params: P } },
def: CommandDef<P>,
...args: P extends void ? [] : [params: P]
) => void;
onNotification: <D>(
def: { method: string; _types?: { data: D } },
def: NotificationDef<D>,
cb: (data: D) => void,
) => () => void;
},
): ApiHook<Api>;
export function buildApiHook(
api: Record<string, { kind: string; method: string }>,
api: Record<
string,
| RequestDef<unknown, unknown>
| CommandDef<unknown>
| NotificationDef<unknown>
>,
ipc: {
request: (def: { method: string }, params?: unknown) => Promise<unknown>;
command: (def: { method: string }, params?: unknown) => void;
request: (
def: RequestDef<unknown, unknown>,
params?: unknown,
) => Promise<unknown>;
command: (def: CommandDef<unknown>, params?: unknown) => void;
onNotification: (
def: { method: string },
def: NotificationDef<unknown>,
cb: (data: unknown) => void,
) => () => void;
},
Expand Down
24 changes: 24 additions & 0 deletions packages/shared/src/speedtest/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { defineCommand, defineNotification } from "../ipc/protocol";

export interface SpeedtestInterval {
start_time_seconds: number;
end_time_seconds: number;
throughput_mbits: number;
}

export interface SpeedtestResult {
overall: SpeedtestInterval;
intervals: SpeedtestInterval[];
}

export interface SpeedtestData {
workspaceName: string;
result: SpeedtestResult;
}

export const SpeedtestApi = {
/** Extension pushes parsed results to the webview */
data: defineNotification<SpeedtestData>("speedtest/data"),
/** Webview requests to open raw JSON in a text editor */
viewJson: defineCommand<void>("speedtest/viewJson"),
} as const;
21 changes: 21 additions & 0 deletions packages/speedtest/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@repo/speedtest",
"version": "1.0.0",
"description": "Coder Speedtest visualization 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:"
}
}
Loading