diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b7a3e63..2a82613f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@
from published versions since it shows up in the VS Code extension changelog
tab and is confusing to users. Add it back between releases if needed. -->
+## Unreleased
+
+### 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
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 4cfcd965..2c3289c6 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -17,7 +17,6 @@ export default defineConfig(
"**/*.d.ts",
"vitest.config.ts",
"**/vite.config*.ts",
- "**/createWebviewConfig.ts",
".vscode-test/**",
"test/fixtures/scripts/**",
]),
diff --git a/packages/shared/src/error/utils.ts b/packages/shared/src/error/utils.ts
new file mode 100644
index 00000000..bbb7ff9d
--- /dev/null
+++ b/packages/shared/src/error/utils.ts
@@ -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");
+ }
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 2da0281c..8f9ccef3 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1,7 +1,18 @@
// 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";
diff --git a/packages/shared/src/ipc/protocol.ts b/packages/shared/src/ipc/protocol.ts
index f30c3208..2f594b64 100644
--- a/packages/shared/src/ipc/protocol.ts
+++ b/packages/shared/src/ipc/protocol.ts
@@ -166,26 +166,34 @@ export function buildApiHook<
api: Api,
ipc: {
request:
(
- def: { method: string; _types?: { params: P; response: R } },
+ def: RequestDef
,
...args: P extends void ? [] : [params: P]
) => Promise;
command: (
- def: { method: string; _types?: { params: P } },
+ def: CommandDef
,
...args: P extends void ? [] : [params: P]
) => void;
onNotification: (
- def: { method: string; _types?: { data: D } },
+ def: NotificationDef,
cb: (data: D) => void,
) => () => void;
},
): ApiHook;
export function buildApiHook(
- api: Record,
+ api: Record<
+ string,
+ | RequestDef
+ | CommandDef
+ | NotificationDef
+ >,
ipc: {
- request: (def: { method: string }, params?: unknown) => Promise;
- command: (def: { method: string }, params?: unknown) => void;
+ request: (
+ def: RequestDef,
+ params?: unknown,
+ ) => Promise;
+ command: (def: CommandDef, params?: unknown) => void;
onNotification: (
- def: { method: string },
+ def: NotificationDef,
cb: (data: unknown) => void,
) => () => void;
},
diff --git a/packages/shared/src/speedtest/api.ts b/packages/shared/src/speedtest/api.ts
new file mode 100644
index 00000000..98a16d49
--- /dev/null
+++ b/packages/shared/src/speedtest/api.ts
@@ -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("speedtest/data"),
+ /** Webview requests to open raw JSON in a text editor */
+ viewJson: defineCommand("speedtest/viewJson"),
+} as const;
diff --git a/packages/speedtest/package.json b/packages/speedtest/package.json
new file mode 100644
index 00000000..9fa4c727
--- /dev/null
+++ b/packages/speedtest/package.json
@@ -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:"
+ }
+}
diff --git a/packages/speedtest/src/chart.ts b/packages/speedtest/src/chart.ts
new file mode 100644
index 00000000..f2a93b02
--- /dev/null
+++ b/packages/speedtest/src/chart.ts
@@ -0,0 +1,235 @@
+import { type ChartPoint, formatTick, niceStep } from "./chartUtils";
+
+const MIN_TICK_SPACING_EM = 4;
+const Y_GRID_LINES = 5;
+/** 10% headroom above the max so the line doesn't hug the top edge. */
+const Y_HEADROOM = 1.1;
+const DOT_RADIUS_PX = 4;
+const LINE_WIDTH_PX = 2;
+
+const PLOT_PAD_EM = { top: 2, right: 2, bottom: 3.5 };
+const Y_LABEL_GAP_EM = 1;
+const X_LABEL_GAP_EM = 1.5;
+const X_AXIS_TITLE_GAP_EM = 0.25;
+const Y_AXIS_TITLE_GAP_EM = 1;
+/** Room reserved for the rotated "Mbps" title. */
+const Y_AXIS_TITLE_ROOM_EM = 1.5;
+const LEFT_PAD_EM = Y_AXIS_TITLE_GAP_EM + Y_AXIS_TITLE_ROOM_EM + Y_LABEL_GAP_EM;
+
+interface Theme {
+ fg: string;
+ accent: string;
+ grid: string;
+ family: string;
+}
+
+/** Canvas pixels don't inherit CSS vars, so re-read on every render. */
+function readTheme(): Theme {
+ const s = getComputedStyle(document.documentElement);
+ const css = (prop: string) => s.getPropertyValue(prop).trim();
+ return {
+ fg:
+ css("--vscode-charts-foreground") ||
+ css("--vscode-descriptionForeground") ||
+ css("--vscode-editor-foreground") ||
+ "#888",
+ // focusBorder tracks the theme's accent; charts.blue is a fixed hue
+ // kept as a late fallback.
+ accent:
+ css("--vscode-chart-line") ||
+ css("--vscode-focusBorder") ||
+ css("--vscode-charts-blue") ||
+ "#3794ff",
+ grid:
+ css("--vscode-chart-guide") ||
+ css("--vscode-charts-lines") ||
+ "rgba(127, 127, 127, 0.35)",
+ family: css("--vscode-font-family") || "sans-serif",
+ };
+}
+
+function layoutChart(
+ ctx: CanvasRenderingContext2D,
+ samples: ChartPoint[],
+ width: number,
+ height: number,
+ pxPerEm: number,
+ family: string,
+) {
+ const maxVal = samples.reduce((m, s) => Math.max(m, s.y), 1) * Y_HEADROOM;
+ const maxX = samples.at(-1)?.x ?? 1;
+ ctx.font = `1em ${family}`;
+ const yLabelWidth = ctx.measureText(maxVal.toFixed(0)).width;
+ const pad = {
+ top: PLOT_PAD_EM.top * pxPerEm,
+ right: PLOT_PAD_EM.right * pxPerEm,
+ bottom: PLOT_PAD_EM.bottom * pxPerEm,
+ left: Math.max(
+ PLOT_PAD_EM.right * pxPerEm,
+ yLabelWidth + LEFT_PAD_EM * pxPerEm,
+ ),
+ };
+ const plotW = width - pad.left - pad.right;
+ const plotH = height - pad.top - pad.bottom;
+ return {
+ pad,
+ plotW,
+ plotH,
+ maxVal,
+ maxX,
+ height,
+ tAt: (t: number) => pad.left + (t / maxX) * plotW,
+ yAt: (v: number) => pad.top + plotH - (v / maxVal) * plotH,
+ };
+}
+
+type Layout = ReturnType;
+
+function drawAxes(
+ ctx: CanvasRenderingContext2D,
+ layout: Layout,
+ theme: Theme,
+ pxPerEm: number,
+): void {
+ const { pad, plotW, plotH, maxVal, maxX, height, tAt, yAt } = layout;
+
+ ctx.strokeStyle = theme.grid;
+ ctx.lineWidth = 1;
+ ctx.fillStyle = theme.fg;
+ ctx.textAlign = "right";
+ for (let i = 0; i <= Y_GRID_LINES; i++) {
+ const v = (i / Y_GRID_LINES) * maxVal;
+ const y = yAt(v);
+ ctx.beginPath();
+ ctx.moveTo(pad.left, y);
+ ctx.lineTo(pad.left + plotW, y);
+ ctx.stroke();
+ ctx.fillText(
+ v.toFixed(0),
+ pad.left - Y_LABEL_GAP_EM * pxPerEm,
+ y + pxPerEm / 3,
+ );
+ }
+
+ ctx.strokeStyle = theme.fg;
+ ctx.beginPath();
+ ctx.moveTo(pad.left, pad.top + plotH);
+ ctx.lineTo(pad.left + plotW, pad.top + plotH);
+ ctx.stroke();
+
+ ctx.textAlign = "center";
+ const step = niceStep(
+ maxX / Math.max(1, Math.floor(plotW / (MIN_TICK_SPACING_EM * pxPerEm))),
+ );
+ for (let t = 0; t <= maxX; t += step) {
+ ctx.fillText(
+ formatTick(t, step),
+ tAt(t),
+ height - pad.bottom + X_LABEL_GAP_EM * pxPerEm,
+ );
+ }
+
+ ctx.font = `0.95em ${theme.family}`;
+ ctx.fillText(
+ "Time",
+ pad.left + plotW / 2,
+ height - X_AXIS_TITLE_GAP_EM * pxPerEm,
+ );
+ ctx.save();
+ ctx.translate(Y_AXIS_TITLE_GAP_EM * pxPerEm, pad.top + plotH / 2);
+ ctx.rotate(-Math.PI / 2);
+ ctx.fillText("Mbps", 0, 0);
+ ctx.restore();
+}
+
+function drawSeries(
+ ctx: CanvasRenderingContext2D,
+ samples: ChartPoint[],
+ layout: Layout,
+ theme: Theme,
+ showDots: boolean,
+): ChartPoint[] {
+ const { pad, plotH, tAt, yAt } = layout;
+ const baseline = pad.top + plotH;
+ const first = samples[0];
+ const last = samples.at(-1) ?? first;
+
+ if (first.x > 0) {
+ ctx.beginPath();
+ ctx.moveTo(tAt(0), baseline);
+ ctx.lineTo(tAt(first.x), yAt(first.y));
+ ctx.setLineDash([4, 4]);
+ ctx.strokeStyle = theme.accent;
+ ctx.lineWidth = 1;
+ ctx.globalAlpha = 0.4;
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.globalAlpha = 1;
+ }
+
+ ctx.beginPath();
+ ctx.moveTo(tAt(first.x), baseline);
+ for (const s of samples) {
+ ctx.lineTo(tAt(s.x), yAt(s.y));
+ }
+ ctx.lineTo(tAt(last.x), baseline);
+ ctx.closePath();
+ const grad = ctx.createLinearGradient(0, pad.top, 0, baseline);
+ grad.addColorStop(0, theme.accent + "18");
+ grad.addColorStop(1, theme.accent + "04");
+ ctx.fillStyle = grad;
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.moveTo(tAt(first.x), yAt(first.y));
+ for (let i = 1; i < samples.length; i++) {
+ ctx.lineTo(tAt(samples[i].x), yAt(samples[i].y));
+ }
+ ctx.strokeStyle = theme.accent;
+ ctx.lineWidth = LINE_WIDTH_PX;
+ ctx.stroke();
+
+ return samples.map((s) => {
+ const x = tAt(s.x);
+ const y = yAt(s.y);
+ if (showDots) {
+ ctx.beginPath();
+ ctx.arc(x, y, DOT_RADIUS_PX, 0, Math.PI * 2);
+ ctx.fillStyle = theme.accent;
+ ctx.fill();
+ }
+ return { x, y, label: s.label };
+ });
+}
+
+/** Render the speedtest chart. Caller must ensure `samples` is non-empty. */
+export function renderLineChart(
+ canvas: HTMLCanvasElement,
+ samples: ChartPoint[],
+ showDots: boolean,
+): ChartPoint[] {
+ // Scale backing store by DPR so drawing stays crisp on high-DPI screens.
+ const parent = canvas.parentElement ?? canvas;
+ const { width, height } = parent.getBoundingClientRect();
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return [];
+ }
+ ctx.scale(dpr, dpr);
+
+ const pxPerEm = parseFloat(getComputedStyle(parent).fontSize) || 14;
+ const theme = readTheme();
+ const layout = layoutChart(
+ ctx,
+ samples,
+ width,
+ height,
+ pxPerEm,
+ theme.family,
+ );
+ drawAxes(ctx, layout, theme, pxPerEm);
+ return drawSeries(ctx, samples, layout, theme, showDots);
+}
diff --git a/packages/speedtest/src/chartUtils.ts b/packages/speedtest/src/chartUtils.ts
new file mode 100644
index 00000000..0333e8b5
--- /dev/null
+++ b/packages/speedtest/src/chartUtils.ts
@@ -0,0 +1,103 @@
+import type { SpeedtestResult } from "@repo/shared";
+
+export interface ChartPoint {
+ x: number;
+ y: number;
+ label: string;
+}
+
+export const HIT_RADIUS_PX = 12;
+
+/** Candidate x-axis tick step sizes in seconds (1s, 2s, 5s, ..., 30m, 1h). */
+const TICK_STEP_SECONDS = [
+ 1, 2, 5, 10, 15, 20, 30, 60, 120, 300, 600, 900, 1800, 3600,
+];
+
+/** Round up a raw tick size (seconds) to the next friendly candidate. */
+export function niceStep(raw: number): number {
+ return (
+ TICK_STEP_SECONDS.find((s) => s >= raw) ?? Math.ceil(raw / 3600) * 3600
+ );
+}
+
+/** Format a tick value as `Ns`, `Nm`, or `Nh` depending on the step size. */
+export function formatTick(t: number, step: number): string {
+ if (step >= 3600) {
+ const h = t / 3600;
+ return `${Number.isInteger(h) ? h : h.toFixed(1)}h`;
+ }
+ if (step >= 60) {
+ const m = t / 60;
+ return `${Number.isInteger(m) ? m : m.toFixed(1)}m`;
+ }
+ return `${t}s`;
+}
+
+/** Convert speedtest intervals into chart points with hover labels. */
+export function toChartSamples(
+ intervals: SpeedtestResult["intervals"],
+): ChartPoint[] {
+ return intervals.map((iv) => ({
+ x: iv.end_time_seconds,
+ y: iv.throughput_mbits,
+ label: `${iv.throughput_mbits.toFixed(2)} Mbps (${iv.start_time_seconds.toFixed(0)}\u2013${iv.end_time_seconds.toFixed(0)}s)`,
+ }));
+}
+
+/** Nearest point to the cursor within a square hit box; null if out of range. */
+export function findNearestDot(
+ points: ChartPoint[],
+ mx: number,
+ my: number,
+): ChartPoint | null {
+ const best = findNearestByX(points, mx);
+ if (!best) {
+ return null;
+ }
+ return Math.abs(best.x - mx) < HIT_RADIUS_PX &&
+ Math.abs(best.y - my) < HIT_RADIUS_PX
+ ? best
+ : null;
+}
+
+/** Nearest point to the cursor, accepting any x within the average sample gap. */
+export function findNearestOnLine(
+ points: ChartPoint[],
+ mx: number,
+): ChartPoint | null {
+ const best = findNearestByX(points, mx);
+ if (!best) {
+ return null;
+ }
+ const last = points.at(-1) ?? best;
+ const avgGap =
+ points.length > 1
+ ? (last.x - points[0].x) / (points.length - 1)
+ : HIT_RADIUS_PX;
+ return Math.abs(best.x - mx) < avgGap ? best : null;
+}
+
+/** Binary search for the point whose x is closest to `mx`. */
+export function findNearestByX(
+ points: ChartPoint[],
+ mx: number,
+): ChartPoint | null {
+ if (points.length === 0) {
+ return null;
+ }
+ let lo = 0;
+ let hi = points.length - 1;
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1;
+ if (points[mid].x < mx) {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+ let best = points[lo];
+ if (lo > 0 && Math.abs(points[lo - 1].x - mx) < Math.abs(best.x - mx)) {
+ best = points[lo - 1];
+ }
+ return best;
+}
diff --git a/packages/speedtest/src/css.d.ts b/packages/speedtest/src/css.d.ts
new file mode 100644
index 00000000..cbe652db
--- /dev/null
+++ b/packages/speedtest/src/css.d.ts
@@ -0,0 +1 @@
+declare module "*.css";
diff --git a/packages/speedtest/src/index.css b/packages/speedtest/src/index.css
new file mode 100644
index 00000000..f9eb68b2
--- /dev/null
+++ b/packages/speedtest/src/index.css
@@ -0,0 +1,114 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ padding: 1.5em;
+ min-width: 22em;
+ background: var(--vscode-editor-background);
+ color: var(--vscode-editor-foreground);
+ font-family: var(--vscode-font-family);
+ font-size: var(--vscode-font-size);
+}
+
+.workspace-name {
+ margin: 0 0 2em;
+ font-size: 1.8em;
+ font-weight: 600;
+ text-align: center;
+ word-break: break-all;
+}
+
+.summary {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 1em 3em;
+ margin-bottom: 1.5em;
+ text-align: center;
+}
+
+.stat-label {
+ display: block;
+ font-size: 0.8em;
+ opacity: 0.6;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.25em;
+}
+
+.stat-value {
+ font-size: 1.8em;
+ font-weight: 600;
+}
+
+.stat-value small {
+ font-size: 0.55em;
+ font-weight: 400;
+ opacity: 0.7;
+}
+
+.chart-container {
+ position: relative;
+ height: 20em;
+ margin-bottom: 1.25em;
+}
+
+.chart-container canvas {
+ width: 100%;
+ height: 100%;
+}
+
+.actions {
+ display: flex;
+ justify-content: center;
+}
+
+button {
+ padding: 0.4em 1em;
+ border: 1px solid var(--vscode-button-border, transparent);
+ border-radius: 2px;
+ background: var(--vscode-button-secondaryBackground);
+ color: var(--vscode-button-secondaryForeground);
+ font: inherit;
+ cursor: pointer;
+}
+
+button:hover {
+ background: var(--vscode-button-secondaryHoverBackground);
+}
+
+.tooltip {
+ position: absolute;
+ padding: 0.25em 0.5em;
+ border-radius: 3px;
+ background: var(--vscode-editorHoverWidget-background);
+ border: 1px solid var(--vscode-editorHoverWidget-border);
+ color: var(--vscode-editorHoverWidget-foreground);
+ font-size: 0.9em;
+ white-space: nowrap;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.1s;
+}
+
+.tooltip.visible {
+ opacity: 1;
+}
+
+.error,
+.empty {
+ text-align: center;
+ margin: 2em 0;
+}
+
+.error {
+ color: var(--vscode-errorForeground);
+}
+
+.empty {
+ opacity: 0.7;
+}
diff --git a/packages/speedtest/src/index.ts b/packages/speedtest/src/index.ts
new file mode 100644
index 00000000..bcc71364
--- /dev/null
+++ b/packages/speedtest/src/index.ts
@@ -0,0 +1,181 @@
+import { SpeedtestApi, type SpeedtestResult, toError } from "@repo/shared";
+import { postMessage, subscribeNotification } from "@repo/webview-shared";
+
+import { renderLineChart } from "./chart";
+import {
+ type ChartPoint,
+ findNearestDot,
+ findNearestOnLine,
+ toChartSamples,
+} from "./chartUtils";
+import "./index.css";
+
+/** Above this sample count, render the line alone (no per-point dots). */
+const DOT_THRESHOLD = 20;
+/** Gap in pixels between the tooltip and the point it describes. */
+const TOOLTIP_GAP_PX = 32;
+
+let cleanup: (() => void) | undefined;
+
+function main(): void {
+ subscribeNotification(SpeedtestApi.data, ({ workspaceName, result }) => {
+ try {
+ cleanup?.();
+ cleanup = renderPage(result, workspaceName, () =>
+ postMessage({ method: SpeedtestApi.viewJson.method }),
+ );
+ } catch (err) {
+ showError(`Failed to render speedtest: ${toError(err).message}`);
+ }
+ });
+}
+
+function renderPage(
+ data: SpeedtestResult,
+ workspaceName: string,
+ onViewJson: () => void,
+): () => void {
+ const root = document.getElementById("root");
+ if (!root) {
+ return () => undefined;
+ }
+
+ root.innerHTML = "";
+ root.appendChild(renderHeading(workspaceName));
+ root.appendChild(renderSummary(data));
+
+ const samples = toChartSamples(data.intervals);
+ if (samples.length === 0) {
+ root.appendChild(renderEmptyMessage());
+ root.appendChild(renderActions(onViewJson));
+ return () => undefined;
+ }
+
+ const chart = renderChart(samples);
+ root.appendChild(chart.container);
+ root.appendChild(renderActions(onViewJson));
+ return chart.cleanup;
+}
+
+function renderHeading(workspaceName: string): HTMLElement {
+ const heading = document.createElement("h1");
+ heading.className = "workspace-name";
+ heading.textContent = workspaceName;
+ return heading;
+}
+
+function renderSummary(data: SpeedtestResult): HTMLElement {
+ const summary = document.createElement("div");
+ summary.className = "summary";
+ summary.innerHTML = `
+
+ Throughput
+ ${data.overall.throughput_mbits.toFixed(2)} Mbps
+
+
+ Duration
+ ${data.overall.end_time_seconds.toFixed(1)}s
+
+
+ Intervals
+ ${data.intervals.length}
+
+ `;
+ return summary;
+}
+
+function renderChart(samples: ChartPoint[]): {
+ container: HTMLElement;
+ cleanup: () => void;
+} {
+ const container = document.createElement("div");
+ container.className = "chart-container";
+ const canvas = document.createElement("canvas");
+ const tooltip = document.createElement("div");
+ tooltip.className = "tooltip";
+ container.append(canvas, tooltip);
+
+ const showDots = samples.length <= DOT_THRESHOLD;
+ let points: ChartPoint[] = [];
+ let canvasRect: DOMRect | undefined;
+ const draw = () => {
+ points = renderLineChart(canvas, samples, showDots);
+ canvasRect = canvas.getBoundingClientRect();
+ };
+
+ // ResizeObserver's first callback (fired when the caller appends `container`
+ // to the DOM) drives the initial paint; later fires handle resizes.
+ let rafId = 0;
+ const observer = new ResizeObserver(() => {
+ cancelAnimationFrame(rafId);
+ rafId = requestAnimationFrame(draw);
+ });
+ observer.observe(container);
+
+ const onMouseMove = (e: MouseEvent) => {
+ if (!canvasRect) {
+ return;
+ }
+ const mx = e.clientX - canvasRect.left;
+ const my = e.clientY - canvasRect.top;
+ const hit = showDots
+ ? findNearestDot(points, mx, my)
+ : findNearestOnLine(points, mx);
+ if (!hit) {
+ tooltip.classList.remove("visible");
+ return;
+ }
+ tooltip.textContent = hit.label;
+ const tw = tooltip.offsetWidth;
+ const left = Math.max(
+ 0,
+ Math.min(hit.x - tw / 2, container.offsetWidth - tw),
+ );
+ tooltip.style.left = `${left}px`;
+ tooltip.style.top = `${hit.y - TOOLTIP_GAP_PX}px`;
+ tooltip.classList.add("visible");
+ };
+ const onMouseLeave = () => tooltip.classList.remove("visible");
+ canvas.addEventListener("mousemove", onMouseMove);
+ canvas.addEventListener("mouseleave", onMouseLeave);
+
+ return {
+ container,
+ cleanup: () => {
+ cancelAnimationFrame(rafId);
+ observer.disconnect();
+ canvas.removeEventListener("mousemove", onMouseMove);
+ canvas.removeEventListener("mouseleave", onMouseLeave);
+ },
+ };
+}
+
+function renderActions(onViewJson: () => void): HTMLElement {
+ const actions = document.createElement("div");
+ actions.className = "actions";
+ const viewBtn = document.createElement("button");
+ viewBtn.textContent = "View JSON";
+ viewBtn.addEventListener("click", onViewJson);
+ actions.appendChild(viewBtn);
+ return actions;
+}
+
+function renderEmptyMessage(): HTMLElement {
+ const p = document.createElement("p");
+ p.className = "empty";
+ p.textContent = "No samples returned from the speed test.";
+ return p;
+}
+
+function showError(message: string): void {
+ const root = document.getElementById("root");
+ if (!root) {
+ return;
+ }
+ const p = document.createElement("p");
+ p.className = "error";
+ p.textContent = message;
+ root.replaceChildren(p);
+}
+
+main();
diff --git a/packages/speedtest/tsconfig.json b/packages/speedtest/tsconfig.json
new file mode 100644
index 00000000..e1940bf7
--- /dev/null
+++ b/packages/speedtest/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.packages.json",
+ "compilerOptions": {
+ "paths": {
+ "@repo/shared": ["../shared/src"],
+ "@repo/webview-shared": ["../webview-shared/src"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/packages/speedtest/vite.config.ts b/packages/speedtest/vite.config.ts
new file mode 100644
index 00000000..90634aa2
--- /dev/null
+++ b/packages/speedtest/vite.config.ts
@@ -0,0 +1,3 @@
+import { createWebviewConfig } from "../webview-shared/createWebviewConfig";
+
+export default createWebviewConfig("speedtest", __dirname);
diff --git a/packages/tasks/vite.config.ts b/packages/tasks/vite.config.ts
index 830b84dd..5495da20 100644
--- a/packages/tasks/vite.config.ts
+++ b/packages/tasks/vite.config.ts
@@ -1,3 +1,3 @@
-import { createWebviewConfig } from "../webview-shared/createWebviewConfig";
+import { createReactWebviewConfig } from "../webview-shared/createWebviewConfig";
-export default createWebviewConfig("tasks", __dirname);
+export default createReactWebviewConfig("tasks", __dirname);
diff --git a/packages/webview-shared/createWebviewConfig.ts b/packages/webview-shared/createWebviewConfig.ts
index 484d4b07..f6d8728e 100644
--- a/packages/webview-shared/createWebviewConfig.ts
+++ b/packages/webview-shared/createWebviewConfig.ts
@@ -1,23 +1,21 @@
import babel from "@rolldown/plugin-babel";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import { resolve } from "node:path";
-import { defineConfig, type UserConfig } from "vite";
+import { defineConfig, type PluginOption, type UserConfig } from "vite";
-/**
- * Create a Vite config for a webview package
- * @param webviewName - Name of the webview (used for output path)
- * @param dirname - __dirname of the calling config file
- */
+/** Create a Vite config for a non-framework webview package. */
export function createWebviewConfig(
webviewName: string,
dirname: string,
+ options?: { entry?: string; plugins?: PluginOption[] },
): UserConfig {
const production = process.env.NODE_ENV === "production";
+ const entry = options?.entry ?? "src/index.ts";
return defineConfig({
// Use relative URLs for assets (fonts, etc.) in CSS
base: "./",
- plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
+ plugins: options?.plugins ?? [],
build: {
outDir: resolve(dirname, `../../dist/webviews/${webviewName}`),
emptyOutDir: true,
@@ -29,7 +27,7 @@ export function createWebviewConfig(
chunkSizeWarningLimit: 600,
rollupOptions: {
// HTML is generated by the extension with CSP headers
- input: resolve(dirname, "src/index.tsx"),
+ input: resolve(dirname, entry),
output: {
entryFileNames: "index.js",
// Keep fonts with original names for proper CSS references
@@ -51,3 +49,14 @@ export function createWebviewConfig(
},
});
}
+
+/** Create a Vite config for a React-based webview package. */
+export function createReactWebviewConfig(
+ webviewName: string,
+ dirname: string,
+): UserConfig {
+ return createWebviewConfig(webviewName, dirname, {
+ entry: "src/index.tsx",
+ plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
+ });
+}
diff --git a/packages/webview-shared/src/index.ts b/packages/webview-shared/src/index.ts
index b2588950..334b27df 100644
--- a/packages/webview-shared/src/index.ts
+++ b/packages/webview-shared/src/index.ts
@@ -7,3 +7,6 @@ export interface WebviewMessage {
// VS Code state API
export { getState, setState, postMessage } from "./api";
+
+// Notification subscription for non-React webviews
+export { subscribeNotification } from "./notifications";
diff --git a/packages/webview-shared/src/notifications.ts b/packages/webview-shared/src/notifications.ts
new file mode 100644
index 00000000..e71877b6
--- /dev/null
+++ b/packages/webview-shared/src/notifications.ts
@@ -0,0 +1,20 @@
+import type { NotificationDef } from "@repo/shared";
+
+/** Subscribe to an extension notification. Returns an unsubscribe function. */
+export function subscribeNotification(
+ def: NotificationDef,
+ callback: (data: D) => void,
+): () => void {
+ const handler = (event: MessageEvent) => {
+ const msg = event.data as { type?: string; data?: D } | undefined;
+ if (!msg || typeof msg !== "object") {
+ return;
+ }
+ if (msg.type !== def.method || msg.data === undefined) {
+ return;
+ }
+ callback(msg.data);
+ };
+ window.addEventListener("message", handler);
+ return () => window.removeEventListener("message", handler);
+}
diff --git a/packages/webview-shared/src/react/useIpc.ts b/packages/webview-shared/src/react/useIpc.ts
index b41c3a73..9fe994b3 100644
--- a/packages/webview-shared/src/react/useIpc.ts
+++ b/packages/webview-shared/src/react/useIpc.ts
@@ -6,8 +6,14 @@
import { useEffect, useRef } from "react";
import { postMessage } from "../api";
+import { subscribeNotification } from "../notifications";
-import type { IpcNotification, IpcResponse } from "@repo/shared";
+import type {
+ CommandDef,
+ IpcResponse,
+ NotificationDef,
+ RequestDef,
+} from "@repo/shared";
interface PendingRequest {
resolve: (value: unknown) => void;
@@ -22,8 +28,6 @@ export interface UseIpcOptions {
timeoutMs?: number;
}
-type NotificationHandler = (data: unknown) => void;
-
/**
* Hook for type-safe IPC with the extension.
*
@@ -44,11 +48,8 @@ type NotificationHandler = (data: unknown) => void;
export function useIpc(options: UseIpcOptions = {}) {
const { timeoutMs = DEFAULT_TIMEOUT_MS } = options;
const pendingRequestsRef = useRef