From 9797ef2650eed6e47247f1e0483747c3bd077513 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 22 May 2026 20:57:00 +0300 Subject: [PATCH] chore: lower min VS Code to 1.105, remove chat sidebar, harden deeplink params - engines.vscode -> ^1.105.0, @types/vscode -> 1.105.0, CI integration matrix pinned at 1.105.1 so the latest Cursor stable can install again. - Remove the Coder Chat (Experimental) secondary sidebar and every supporting piece: webview provider, agents experiment fetch, coder.agentsEnabled context, chatId deeplink handoff, pendingChatId memento helpers, @repo/chat iframe shim package, ChatApi IPC contract. - Add regression tests pinning the permissive deeplink contract so older Coder servers still emitting chatId and newer servers emitting unknown params both keep working. Closes #974 --- .github/workflows/ci.yaml | 4 +- .vscode-test.mjs | 4 +- CHANGELOG.md | 7 + CONTRIBUTING.md | 4 +- README.md | 3 - package.json | 35 +-- packages/chat/package.json | 21 -- packages/chat/src/css.d.ts | 1 - packages/chat/src/index.css | 39 --- packages/chat/src/index.ts | 121 --------- packages/chat/tsconfig.json | 10 - packages/chat/vite.config.ts | 3 - packages/shared/src/chat/api.ts | 20 -- packages/shared/src/index.ts | 3 - packages/webview-shared/README.md | 21 +- pnpm-lock.yaml | 29 +- src/core/commandManager.ts | 1 - src/core/contextManager.ts | 1 - src/core/mementoManager.ts | 19 -- src/deployment/deploymentManager.ts | 26 +- src/extension.ts | 30 --- src/uri/uriHandler.ts | 43 +-- src/webviews/chat/chatPanelProvider.ts | 252 ------------------ test/unit/core/mementoManager.test.ts | 30 --- test/unit/uri/uriHandler.test.ts | 62 +++-- .../webviews/chat/chatPanelProvider.test.ts | 234 ---------------- test/webview/chat/index.test.ts | 117 -------- 27 files changed, 83 insertions(+), 1057 deletions(-) delete mode 100644 packages/chat/package.json delete mode 100644 packages/chat/src/css.d.ts delete mode 100644 packages/chat/src/index.css delete mode 100644 packages/chat/src/index.ts delete mode 100644 packages/chat/tsconfig.json delete mode 100644 packages/chat/vite.config.ts delete mode 100644 packages/shared/src/chat/api.ts delete mode 100644 src/webviews/chat/chatPanelProvider.ts delete mode 100644 test/unit/webviews/chat/chatPanelProvider.test.ts delete mode 100644 test/webview/chat/index.test.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 13de9a0290..dc7049fd9f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,7 +33,7 @@ jobs: strategy: fail-fast: false matrix: - # Minimum supported version: VS Code 1.106 (Oct 2025) -> Electron 37 -> Node 22 + # Minimum supported version: VS Code 1.105 (Sept 2025) -> Electron 37 -> Node 22 # See https://github.com/ewanharris/vscode-versions for version mapping. # Older Electron stays Linux-only; "latest" smoke-tests Windows + macOS too. include: @@ -60,7 +60,7 @@ jobs: fail-fast: false matrix: include: - - { os: ubuntu-24.04, name: Linux, vscode-version: "1.106.0" } + - { os: ubuntu-24.04, name: Linux, vscode-version: "1.105.0" } - { os: ubuntu-24.04, name: Linux, vscode-version: "stable" } - { os: windows-2025, name: Windows, vscode-version: "stable" } - { os: macos-15, name: macOS, vscode-version: "stable" } diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 7e1e9f66c8..e7f325629a 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,10 +1,10 @@ import { defineConfig } from "@vscode/test-cli"; // VS Code to Electron/Node version mapping: -// VS Code 1.106 (Oct 2025) -> Electron 37, Node 22 - Minimum supported +// VS Code 1.105 (Sept 2025) -> Electron 37, Node 22 - Minimum supported // VS Code stable -> Latest // See https://github.com/ewanharris/vscode-versions for version mapping -const versions = ["1.106.0", "stable"]; +const versions = ["1.105.0", "stable"]; const baseConfig = { files: "out/test/integration/**/*.test.js", diff --git a/CHANGELOG.md b/CHANGELOG.md index c631dfb1a6..82ac9c8c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,13 @@ around `cmd.exe` on Windows) and a recurring command-injection risk when deployment-supplied values like workspace names or template parameters contained spaces, quotes, or shell metacharacters. +- Minimum supported VS Code lowered to 1.105 for Cursor compatibility. + +### Removed + +- The "Coder Chat (Experimental)" secondary sidebar and its `agents` + experiment gate. Deeplinks that still include `chatId` continue to open + the workspace; the parameter is now silently ignored. ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36251b99b3..6b0aca7876 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,8 +66,6 @@ The extension provides several sidebar panels: indicators, quick actions, and search. - **Coder Tasks** - a React webview for creating, monitoring, and managing AI agent tasks with real-time log streaming. -- **Coder Chat** - an embedded chat UI for delegating tasks to AI coding agents - (gated behind the `coder.agentsEnabled` context flag). There are also notifications for outdated workspace templates and for workspaces that are close to shutting down. @@ -176,7 +174,7 @@ This extension targets the Node.js version bundled with VS Code's Electron: | VS Code | Electron | Node.js | Status | | ------- | -------- | ------- | ----------------- | -| 1.106 | 37 | 22 | Minimum supported | +| 1.105 | 37 | 22 | Minimum supported | | stable | latest | varies | Also tested in CI | When updating the minimum Node.js version, update these files: diff --git a/README.md b/README.md index 431d053284..a06202c88a 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,6 @@ The Coder Remote extension connects your editor to metadata and app statuses at a glance. - **Coder Tasks** - create, monitor, and manage AI agent tasks directly from the sidebar with real-time log streaming. -- **Coder Chat** - delegate development tasks to AI coding agents from the - sidebar. Requires [Coder Agents](https://coder.com/docs/ai-coder/agents) to - be enabled on your deployment. - **Multi-deployment support** - connect to multiple Coder deployments and switch between them without losing credentials. - **Dev container support** - open dev containers running inside workspaces. diff --git a/package.json b/package.json index 9e3f781138..5436ea0f55 100644 --- a/package.json +++ b/package.json @@ -278,13 +278,6 @@ "title": "Coder Tasks", "icon": "media/tasks-logo.svg" } - ], - "secondarySidebar": [ - { - "id": "coderChat", - "title": "Coder Chat (Experimental)", - "icon": "media/shorthand-logo.svg" - } ] }, "views": { @@ -317,15 +310,6 @@ "icon": "media/tasks-logo.svg", "when": "coder.authenticated" } - ], - "coderChat": [ - { - "type": "webview", - "id": "coder.chatPanel", - "name": "Coder Chat (Experimental)", - "icon": "media/shorthand-logo.svg", - "when": "coder.agentsEnabled" - } ] }, "viewsWelcome": [ @@ -449,12 +433,6 @@ "category": "Coder", "icon": "$(refresh)" }, - { - "command": "coder.chat.refresh", - "title": "Refresh Chat", - "category": "Coder", - "icon": "$(refresh)" - }, { "command": "coder.applyRecommendedSettings", "title": "Apply Recommended SSH Settings", @@ -571,10 +549,6 @@ "command": "coder.tasks.refresh", "when": "false" }, - { - "command": "coder.chat.refresh", - "when": "false" - }, { "command": "coder.applyRecommendedSettings" } @@ -616,11 +590,6 @@ "command": "coder.tasks.refresh", "when": "coder.authenticated && view == coder.tasksPanel", "group": "navigation@1" - }, - { - "command": "coder.chat.refresh", - "when": "view == coder.chatPanel", - "group": "navigation@1" } ], "view/item/context": [ @@ -713,7 +682,7 @@ "@types/react-dom": "catalog:", "@types/semver": "^7.7.1", "@types/ua-parser-js": "0.7.39", - "@types/vscode": "1.106.0", + "@types/vscode": "1.105.0", "@types/vscode-webview": "catalog:", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.59.3", @@ -758,7 +727,7 @@ ], "packageManager": "pnpm@11.1.2+sha224.6d287705d0efa6c2ba7a74795dc72a7800f64840f2bc0961cedba029", "engines": { - "vscode": "^1.106.0", + "vscode": "^1.105.0", "node": ">= 22" }, "icon": "media/logo.png", diff --git a/packages/chat/package.json b/packages/chat/package.json deleted file mode 100644 index 4669c50304..0000000000 --- a/packages/chat/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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:" - } -} diff --git a/packages/chat/src/css.d.ts b/packages/chat/src/css.d.ts deleted file mode 100644 index cbe652dbe0..0000000000 --- a/packages/chat/src/css.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "*.css"; diff --git a/packages/chat/src/index.css b/packages/chat/src/index.css deleted file mode 100644 index 14fac54a75..0000000000 --- a/packages/chat/src/index.css +++ /dev/null @@ -1,39 +0,0 @@ -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); -} diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts deleted file mode 100644 index 701b75054b..0000000000 --- a/packages/chat/src/index.ts +++ /dev/null @@ -1,121 +0,0 @@ -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 { - 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(); diff --git a/packages/chat/tsconfig.json b/packages/chat/tsconfig.json deleted file mode 100644 index e1940bf7a8..0000000000 --- a/packages/chat/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../tsconfig.packages.json", - "compilerOptions": { - "paths": { - "@repo/shared": ["../shared/src"], - "@repo/webview-shared": ["../webview-shared/src"] - } - }, - "include": ["src"] -} diff --git a/packages/chat/vite.config.ts b/packages/chat/vite.config.ts deleted file mode 100644 index cb12ddf2cc..0000000000 --- a/packages/chat/vite.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createWebviewConfig } from "../webview-shared/createWebviewConfig"; - -export default createWebviewConfig("chat", __dirname); diff --git a/packages/shared/src/chat/api.ts b/packages/shared/src/chat/api.ts deleted file mode 100644 index 2761ada369..0000000000 --- a/packages/shared/src/chat/api.ts +++ /dev/null @@ -1,20 +0,0 @@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b403822e58..8f9ccef3a6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,6 +16,3 @@ export { type SpeedtestInterval, type SpeedtestResult, } from "./speedtest/api"; - -// Chat API -export { ChatApi } from "./chat/api"; diff --git a/packages/webview-shared/README.md b/packages/webview-shared/README.md index 4756e2ce2f..9be9c0eede 100644 --- a/packages/webview-shared/README.md +++ b/packages/webview-shared/README.md @@ -16,7 +16,7 @@ Defined in `packages/shared/src/ipc/protocol.ts`: - `defineRequest(method)` - webview to extension, awaits a response Group them in one `Api` const at `packages/shared/src//api.ts` -(see `chat/api.ts`, `speedtest/api.ts`, `tasks/api.ts`). +(see `speedtest/api.ts`, `tasks/api.ts`). ## Where each handler lives @@ -37,16 +37,15 @@ Compile-time exhaustiveness fails the build in three places: Use these as the working blueprint when writing a new webview. The helpers' JSDoc covers their contracts. -| Concern | Look at | -| --------------------------------------- | ----------------------------------------------------------- | -| Vanilla webview package | `packages/speedtest/` (or `packages/chat/`) | -| React webview package | `packages/tasks/` | -| Extension panel (`WebviewPanel`) | `src/webviews/speedtest/speedtestPanelFactory.ts` | -| Extension panel (`WebviewViewProvider`) | `src/webviews/tasks/tasksPanelProvider.ts` | -| Iframe-embedding panel | `src/webviews/chat/chatPanelProvider.ts` + `packages/chat/` | -| Vite config helper | `packages/webview-shared/createWebviewConfig.ts` | -| Dispatch / lifecycle helpers | `src/webviews/dispatch.ts` | -| HTML scaffolding | `src/webviews/html.ts` | +| Concern | Look at | +| --------------------------------------- | ------------------------------------------------- | +| Vanilla webview package | `packages/speedtest/` | +| React webview package | `packages/tasks/` | +| Extension panel (`WebviewPanel`) | `src/webviews/speedtest/speedtestPanelFactory.ts` | +| Extension panel (`WebviewViewProvider`) | `src/webviews/tasks/tasksPanelProvider.ts` | +| Vite config helper | `packages/webview-shared/createWebviewConfig.ts` | +| Dispatch / lifecycle helpers | `src/webviews/dispatch.ts` | +| HTML scaffolding | `src/webviews/html.ts` | ## Re-sending on lifecycle events diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b22887510..93b73c8216 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,8 +188,8 @@ importers: specifier: 0.7.39 version: 0.7.39 '@types/vscode': - specifier: 1.106.0 - version: 1.106.0 + specifier: 1.105.0 + version: 1.105.0 '@types/vscode-webview': specifier: 'catalog:' version: 1.57.5 @@ -305,25 +305,6 @@ importers: specifier: ^4.1.6 version: 4.1.6(@types/node@22.19.19)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.13) - packages/chat: - dependencies: - '@repo/shared': - specifier: workspace:* - version: link:../shared - '@repo/webview-shared': - specifier: workspace:* - version: link:../webview-shared - devDependencies: - '@types/vscode-webview': - specifier: 'catalog:' - version: 1.57.5 - typescript: - specifier: 'catalog:' - version: 6.0.3 - vite: - specifier: 'catalog:' - version: 8.0.13(@types/node@22.19.19)(esbuild@0.28.0) - packages/mocks: dependencies: '@repo/shared': @@ -2030,8 +2011,8 @@ packages: '@types/vscode-webview@1.57.5': resolution: {integrity: sha512-iBAUYNYkz+uk1kdsq05fEcoh8gJmwT3lqqFPN7MGyjQ3HVloViMdo7ZJ8DFIP8WOK74PjOEilosqAyxV2iUFUw==} - '@types/vscode@1.106.0': - resolution: {integrity: sha512-88oUcEl9Wmlyt64pbvjLzyyFuFuPotdjwy+P+5ggg3DyTJSMWJD3ShX2ppya5mqrAYTKEhcaJBerdc5JTeb32w==} + '@types/vscode@1.105.0': + resolution: {integrity: sha512-Lotk3CTFlGZN8ray4VxJE7axIyLZZETQJVWi/lYoUVQuqfRxlQhVOfoejsD2V3dVXPSbS15ov5ZyowMAzgUqcw==} '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -6668,7 +6649,7 @@ snapshots: '@types/vscode-webview@1.57.5': {} - '@types/vscode@1.106.0': {} + '@types/vscode@1.105.0': {} '@types/ws@8.18.1': dependencies: diff --git a/src/core/commandManager.ts b/src/core/commandManager.ts index 241183c183..5b30bda277 100644 --- a/src/core/commandManager.ts +++ b/src/core/commandManager.ts @@ -31,7 +31,6 @@ export const CODER_COMMAND_IDS = [ "coder.supportBundle", "coder.supportBundle:views", "coder.tasks.refresh", - "coder.chat.refresh", ] as const; export type CoderCommandId = (typeof CODER_COMMAND_IDS)[number]; diff --git a/src/core/contextManager.ts b/src/core/contextManager.ts index 8facb2eafe..60d3cfa65e 100644 --- a/src/core/contextManager.ts +++ b/src/core/contextManager.ts @@ -4,7 +4,6 @@ const CONTEXT_DEFAULTS = { "coder.authenticated": false, "coder.isOwner": false, "coder.loaded": false, - "coder.agentsEnabled": false, "coder.workspace.connected": false, "coder.workspace.updatable": false, } as const; diff --git a/src/core/mementoManager.ts b/src/core/mementoManager.ts index a1a1e70171..b868532659 100644 --- a/src/core/mementoManager.ts +++ b/src/core/mementoManager.ts @@ -72,25 +72,6 @@ export class MementoManager { return value ?? "none"; } - /** Store a chat ID to open after a remote-authority reload. */ - public async setPendingChatId(chatId: string): Promise { - await this.setStamped("pendingChatId", chatId); - } - - /** Read and clear the pending chat ID (undefined if none). */ - public async getAndClearPendingChatId(): Promise { - const chatId = this.getStamped("pendingChatId"); - if (chatId !== undefined) { - await this.memento.update("pendingChatId", undefined); - } - return chatId; - } - - /** Clear the pending chat ID without reading it. */ - public async clearPendingChatId(): Promise { - await this.memento.update("pendingChatId", undefined); - } - private async setStamped(key: string, value: T): Promise { await this.memento.update(key, { value, setAt: Date.now() }); } diff --git a/src/deployment/deploymentManager.ts b/src/deployment/deploymentManager.ts index 738f87b776..80af1ac4da 100644 --- a/src/deployment/deploymentManager.ts +++ b/src/deployment/deploymentManager.ts @@ -14,7 +14,7 @@ import { type DeploymentWithAuth, } from "./types"; -import type { Experiment, User } from "coder/site/src/api/typesGenerated"; +import type { User } from "coder/site/src/api/typesGenerated"; import type * as vscode from "vscode"; /** @@ -141,7 +141,6 @@ export class DeploymentManager implements vscode.Disposable { this.registerAuthListener(); // Contexts must be set before refresh (providers check isAuthenticated) this.updateAuthContexts(deployment.user); - this.updateExperimentContexts(); this.refreshWorkspaces(); const deploymentWithoutAuth: Deployment = @@ -172,7 +171,6 @@ export class DeploymentManager implements vscode.Disposable { this.oauthSessionManager.clearDeployment(); this.client.setCredentials(undefined, undefined); this.updateAuthContexts(undefined); - this.contextManager.set("coder.agentsEnabled", false); this.clearWorkspaces(); } @@ -258,28 +256,6 @@ export class DeploymentManager implements vscode.Disposable { this.contextManager.set("coder.isOwner", isOwner); } - /** - * Fetch enabled experiments and update context keys. - * Runs in the background so it does not block login. - */ - private updateExperimentContexts(): void { - this.client - .getExperiments() - .then((experiments) => { - if (!this.isAuthenticated()) { - return; - } - this.contextManager.set( - "coder.agentsEnabled", - experiments.includes("agents" as Experiment), - ); - }) - .catch((err) => { - this.logger.warn("Failed to fetch experiments", err); - this.contextManager.set("coder.agentsEnabled", false); - }); - } - /** * Refresh all workspace providers asynchronously. */ diff --git a/src/extension.ts b/src/extension.ts index 6e4554ec38..08c3bc582a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,7 +23,6 @@ import { Remote } from "./remote/remote"; import { getRemoteSshExtension } from "./remote/sshExtension"; import { registerUriHandler } from "./uri/uriHandler"; import { initVscodeProposed } from "./vscodeProposed"; -import { ChatPanelProvider } from "./webviews/chat/chatPanelProvider"; import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider"; import { WorkspaceProvider, @@ -248,30 +247,11 @@ async function doActivate( ), ); - // Register Chat embed panel with dependencies - const chatPanelProvider = new ChatPanelProvider( - ctx.extensionUri, - client, - output, - ); - commandManager.register("coder.chat.refresh", () => - chatPanelProvider.refresh(), - ); - ctx.subscriptions.push( - chatPanelProvider, - vscode.window.registerWebviewViewProvider( - ChatPanelProvider.viewType, - chatPanelProvider, - { webviewOptions: { retainContextWhenHidden: true } }, - ), - ); - ctx.subscriptions.push( registerUriHandler({ serviceContainer, deploymentManager, commands, - chatPanelProvider, }), ); @@ -402,16 +382,6 @@ async function doActivate( token: details.token, }); tracer.setAuthState(deploymentSet ? "valid_token" : "auth_failed"); - - // If a deep link stored a chat agent ID before the - // remote-authority reload, open it now that the - // deployment is configured. - const pendingChatId = await mementoManager.getAndClearPendingChatId(); - if (pendingChatId) { - // Enable eagerly so the view is visible before focus. - contextManager.set("coder.agentsEnabled", true); - chatPanelProvider.openChat(pendingChatId); - } } } catch (ex) { if (ex instanceof CertificateError) { diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index 6942d1c94f..a846b4ef49 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -9,13 +9,11 @@ import { vscodeProposed } from "../vscodeProposed"; import type { Commands } from "../commands"; import type { ServiceContainer } from "../core/container"; import type { DeploymentManager } from "../deployment/deploymentManager"; -import type { ChatPanelProvider } from "../webviews/chat/chatPanelProvider"; interface UriHandlerDeps { serviceContainer: ServiceContainer; deploymentManager: Pick; commands: Pick; - chatPanelProvider: Pick; } interface UriRouteContext extends UriHandlerDeps { @@ -79,41 +77,16 @@ async function handleOpen(ctx: UriRouteContext): Promise { params.has("openRecent") && (!params.get("openRecent") || params.get("openRecent") === "true"); - // Persist the chat ID before commands.open() triggers - // a remote-authority reload that wipes in-memory state. - // The extension picks this up after the reload in activate(). - const chatId = params.get("chatId"); - const mementoManager = serviceContainer.getMementoManager(); - if (chatId) { - await mementoManager.setPendingChatId(chatId); - } - await setupDeployment(params, serviceContainer, deploymentManager); - let opened = false; - try { - opened = await commands.open({ - workspaceOwner: owner, - workspaceName: workspace, - agentName: agent ?? undefined, - folderPath: folder ?? undefined, - openRecent, - useDefaultDirectory: false, - }); - } finally { - // Clear the pending chat ID if commands.open() did not - // actually open a window (user cancelled, or it threw). - if (!opened && chatId) { - await mementoManager.clearPendingChatId(); - } - } - - // Already-open workspace: VS Code refocuses without reloading, - // so activate() won't run. openChat is idempotent if both fire. - if (opened && chatId) { - serviceContainer.getContextManager().set("coder.agentsEnabled", true); - ctx.chatPanelProvider.openChat(chatId); - } + await commands.open({ + workspaceOwner: owner, + workspaceName: workspace, + agentName: agent ?? undefined, + folderPath: folder ?? undefined, + openRecent, + useDefaultDirectory: false, + }); } async function handleOpenDevContainer(ctx: UriRouteContext): Promise { diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts deleted file mode 100644 index 00bdb81d24..0000000000 --- a/src/webviews/chat/chatPanelProvider.ts +++ /dev/null @@ -1,252 +0,0 @@ -import * as vscode from "vscode"; - -import { - buildCommandHandlers, - buildRequestHandlers, - ChatApi, -} from "@repo/shared"; - -import { type CoderApi } from "../../api/coderApi"; -import { type Logger } from "../../logging/logger"; -import { - dispatchCommand, - dispatchRequest, - isIpcCommand, - isIpcRequest, - notifyWebview, -} from "../dispatch"; -import { - buildWebviewCsp, - escapeHtml, - getNonce, - getWebviewAssetUris, -} from "../html"; - -/** - * Webview that embeds the Coder agent chat UI inside an iframe. The - * panel's HTML pre-renders the iframe with `src=embedUrl`; the - * `@repo/chat` bundle attaches listeners and bridges the iframe's - * foreign `{ type, payload }` protocol to `ChatApi`. Auth flow: - * - * 1. Iframe loads /agents/{id}/embed and posts `coder:vscode-ready`. - * 2. Bundle forwards as `ChatApi.vscodeReady`. - * 3. Extension responds with `ChatApi.authBootstrapToken`. - * 4. Bundle forwards the token into the iframe. - */ -export class ChatPanelProvider - implements vscode.WebviewViewProvider, vscode.Disposable -{ - public static readonly viewType = "coder.chatPanel"; - - private view?: vscode.WebviewView; - private disposables: vscode.Disposable[] = []; - private chatId: string | undefined; - private authRetryTimer: ReturnType | undefined; - - private readonly commandHandlers = buildCommandHandlers(ChatApi, { - vscodeReady: () => this.sendAuthToken(), - chatReady: () => this.sendTheme(), - navigate: ({ url }) => this.handleNavigate(url), - }); - private readonly requestHandlers = buildRequestHandlers(ChatApi, {}); - - constructor( - private readonly extensionUri: vscode.Uri, - private readonly client: Pick, - private readonly logger: Logger, - ) {} - - private getTheme(): "light" | "dark" { - const kind = vscode.window.activeColorTheme.kind; - return kind === vscode.ColorThemeKind.Light || - kind === vscode.ColorThemeKind.HighContrastLight - ? "light" - : "dark"; - } - - private sendTheme(): void { - notifyWebview(this.view?.webview, ChatApi.setTheme, { - theme: this.getTheme(), - }); - } - - /** - * Opens the chat panel for the given chat ID. - * Called after a deep link reload via the persisted - * pendingChatId, or directly for testing. - */ - public openChat(chatId: string): void { - if (this.chatId === chatId && this.view) { - this.view.show(true); - return; - } - this.chatId = chatId; - // No-op if unresolved; the focus command triggers resolveWebviewView(). - this.refresh(); - void vscode.commands.executeCommand(`${ChatPanelProvider.viewType}.focus`); - } - - resolveWebviewView( - webviewView: vscode.WebviewView, - _context: vscode.WebviewViewResolveContext, - _token: vscode.CancellationToken, - ): void { - // Clean up state from a previous view instance to avoid - // duplicates if VS Code re-resolves the view. - this.disposeView(); - this.view = webviewView; - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: [ - vscode.Uri.joinPath(this.extensionUri, "dist", "webviews", "chat"), - ], - }; - this.disposables.push( - webviewView.webview.onDidReceiveMessage((message: unknown) => { - if (isIpcRequest(message)) { - void dispatchRequest( - message, - this.requestHandlers, - webviewView.webview, - { logger: this.logger }, - ); - } else if (isIpcCommand(message)) { - void dispatchCommand(message, this.commandHandlers, { - logger: this.logger, - }); - } - }), - vscode.window.onDidChangeActiveColorTheme(() => { - this.sendTheme(); - }), - ); - this.renderView(); - this.disposables.push(webviewView.onDidDispose(() => this.disposeView())); - } - - public refresh(): void { - if (!this.view) { - return; - } - this.renderView(); - } - - private renderView(): void { - if (!this.view) { - throw new Error("renderView called before resolveWebviewView"); - } - const webview = this.view.webview; - const coderUrl = this.client.getHost(); - if (!this.chatId || !coderUrl) { - webview.html = this.getNoAgentHtml(); - return; - } - const embedUrl = `${coderUrl}/agents/${this.chatId}/embed?theme=${this.getTheme()}`; - webview.html = this.getEmbedHtml(webview, embedUrl); - } - - private handleNavigate(url: string): void { - const coderUrl = this.client.getHost(); - if (!url || !coderUrl) { - return; - } - try { - const resolved = new URL(url, coderUrl); - const expected = new URL(coderUrl); - if (resolved.origin === expected.origin) { - void vscode.env.openExternal(vscode.Uri.parse(resolved.toString())); - } - } catch { - this.logger.warn(`Chat: invalid navigate URL: ${url}`); - } - } - - /** - * Attempt to forward the session token to the chat iframe. - * The token may not be available immediately after a reload - * (e.g. deployment setup is still in progress), so we retry - * with exponential backoff before giving up. - */ - private static readonly MAX_AUTH_RETRIES = 5; - private static readonly AUTH_RETRY_BASE_MS = 500; - - private sendAuthToken(attempt = 0): void { - clearTimeout(this.authRetryTimer); - const token = this.client.getSessionToken(); - if (!token) { - if (attempt < ChatPanelProvider.MAX_AUTH_RETRIES) { - const delay = ChatPanelProvider.AUTH_RETRY_BASE_MS * 2 ** attempt; - this.logger.info( - `Chat: no session token yet, retrying in ${delay}ms ` + - `(attempt ${attempt + 1}/${ChatPanelProvider.MAX_AUTH_RETRIES})`, - ); - this.authRetryTimer = setTimeout( - () => this.sendAuthToken(attempt + 1), - delay, - ); - return; - } - this.logger.warn( - "Chat iframe requested auth but no session token available " + - "after all retries", - ); - notifyWebview(this.view?.webview, ChatApi.authError, { - error: "No session token available. Please sign in and retry.", - }); - return; - } - this.logger.info("Chat: forwarding token to iframe"); - notifyWebview(this.view?.webview, ChatApi.authBootstrapToken, { token }); - } - - /** - * Pre-renders the iframe and adds `frame-src` to the CSP. The bundle - * attaches listeners; it doesn't construct the iframe. - */ - private getEmbedHtml(webview: vscode.Webview, embedUrl: string): string { - const nonce = getNonce(); - const frameSrc = new URL(embedUrl).origin; - const { scriptUri, styleUri } = getWebviewAssetUris( - webview, - this.extensionUri, - "chat", - ); - return ` - - - - - - Coder Chat - - - -
Loading chat…
- - - -`; - } - - private getNoAgentHtml(): string { - return /* html */ ` - - -

No active chat session. Open a chat from the Agents tab on your Coder deployment.

`; - } - - private disposeView(): void { - clearTimeout(this.authRetryTimer); - for (const d of this.disposables) { - d.dispose(); - } - this.disposables = []; - } - - dispose(): void { - this.disposeView(); - } -} diff --git a/test/unit/core/mementoManager.test.ts b/test/unit/core/mementoManager.test.ts index f7c19c5d71..8ce3ee1890 100644 --- a/test/unit/core/mementoManager.test.ts +++ b/test/unit/core/mementoManager.test.ts @@ -93,34 +93,4 @@ describe("MementoManager", () => { expect(await mementoManager.getAndClearStartupMode()).toBe("none"); }); }); - - describe("pendingChatId", () => { - it("should store, retrieve, and clear in one call", async () => { - await mementoManager.setPendingChatId("chat-123"); - - expect(await mementoManager.getAndClearPendingChatId()).toBe("chat-123"); - expect(await mementoManager.getAndClearPendingChatId()).toBeUndefined(); - }); - - it("should return undefined when nothing is set", async () => { - expect(await mementoManager.getAndClearPendingChatId()).toBeUndefined(); - }); - - it("should support explicit clear", async () => { - await mementoManager.setPendingChatId("chat-123"); - await mementoManager.clearPendingChatId(); - expect(await mementoManager.getAndClearPendingChatId()).toBeUndefined(); - }); - - it("should expire after 5 minutes", async () => { - await mementoManager.setPendingChatId("chat-123"); - vi.advanceTimersByTime(5 * 60 * 1000 + 1); - expect(await mementoManager.getAndClearPendingChatId()).toBeUndefined(); - }); - - it("should treat legacy bare values as expired", async () => { - await memento.update("pendingChatId", "bare-chat-id"); - expect(await mementoManager.getAndClearPendingChatId()).toBeUndefined(); - }); - }); }); diff --git a/test/unit/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts index bd2dd00b8c..7e448076d6 100644 --- a/test/unit/uri/uriHandler.test.ts +++ b/test/unit/uri/uriHandler.test.ts @@ -97,13 +97,10 @@ function createTestContext() { .mocked(vscode.window.showErrorMessage) .mockResolvedValue(undefined); - const chatPanelProvider = { openChat: vi.fn() }; - registerUriHandler({ serviceContainer: container, deploymentManager, commands, - chatPanelProvider, }); return { @@ -114,7 +111,6 @@ function createTestContext() { oauthCallback, logger, showErrorMessage, - chatPanelProvider, handleUri: registeredHandler!, }; } @@ -167,23 +163,38 @@ describe("uriHandler", () => { }); }); - it("opens chat when chatId is present and open succeeds", async () => { - const { handleUri, commands, chatPanelProvider } = createTestContext(); - commands.open.mockResolvedValue(true); - const query = `owner=o&workspace=w&chatId=chat-123&url=${encodeURIComponent(TEST_URL)}`; + it("ignores unknown query params from older server (chatId)", async () => { + const { handleUri, commands, deploymentManager, showErrorMessage } = + createTestContext(); + const query = `owner=o&workspace=w&chatId=stale-123&url=${encodeURIComponent(TEST_URL)}`; await handleUri(createMockUri("/open", query)); - expect(chatPanelProvider.openChat).toHaveBeenCalledWith("chat-123"); + + expect(deploymentManager.setDeployment).toHaveBeenCalled(); + expect(commands.open).toHaveBeenCalledWith({ + workspaceOwner: "o", + workspaceName: "w", + agentName: undefined, + folderPath: undefined, + openRecent: false, + useDefaultDirectory: false, + }); + expect(showErrorMessage).not.toHaveBeenCalled(); }); - it.each([ - ["no chatId", "owner=o&workspace=w", true], - ["open returns false", "owner=o&workspace=w&chatId=chat-123", false], - ])("does not open chat when %s", async (_label, params, openResult) => { - const { handleUri, commands, chatPanelProvider } = createTestContext(); - commands.open.mockResolvedValue(openResult); - const query = `${params}&url=${encodeURIComponent(TEST_URL)}`; + it("ignores unknown query params from newer server", async () => { + const { handleUri, commands, showErrorMessage } = createTestContext(); + const query = `owner=o&workspace=w&someFutureFlag=1&anotherParam=v&url=${encodeURIComponent(TEST_URL)}`; await handleUri(createMockUri("/open", query)); - expect(chatPanelProvider.openChat).not.toHaveBeenCalled(); + + expect(commands.open).toHaveBeenCalledWith({ + workspaceOwner: "o", + workspaceName: "w", + agentName: undefined, + folderPath: undefined, + openRecent: false, + useDefaultDirectory: false, + }); + expect(showErrorMessage).not.toHaveBeenCalled(); }); }); @@ -208,6 +219,23 @@ describe("uriHandler", () => { "/cfg", ); }); + + it("ignores unknown query params", async () => { + const { handleUri, commands, showErrorMessage } = createTestContext(); + const query = `owner=o&workspace=w&agent=a&devContainerName=c&devContainerFolder=/f&legacyExtra=1&someFutureFlag=on&url=${encodeURIComponent(TEST_URL)}`; + await handleUri(createMockUri("/openDevContainer", query)); + + expect(commands.openDevContainer).toHaveBeenCalledWith( + "o", + "w", + "a", + "c", + "/f", + "", + "", + ); + expect(showErrorMessage).not.toHaveBeenCalled(); + }); }); describe("missing required parameters", () => { diff --git a/test/unit/webviews/chat/chatPanelProvider.test.ts b/test/unit/webviews/chat/chatPanelProvider.test.ts deleted file mode 100644 index 586ee24a79..0000000000 --- a/test/unit/webviews/chat/chatPanelProvider.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as vscode from "vscode"; - -import { ChatPanelProvider } from "@/webviews/chat/chatPanelProvider"; - -import { createMockLogger, MockCoderApi } from "../../../mocks/testHelpers"; - -const windowMock = vscode.window as typeof vscode.window & { - __setActiveColorThemeKind: (kind: number) => void; -}; - -interface Harness { - provider: ChatPanelProvider; - postMessage: ReturnType; - sendFromWebview: (msg: unknown) => void; - html: () => string; -} - -function createHarnessFor(client: MockCoderApi): Harness { - const provider = new ChatPanelProvider( - vscode.Uri.file("/ext"), - client, - createMockLogger(), - ); - - let handler: ((msg: unknown) => void) | null = null; - - const webview: vscode.WebviewView = { - viewType: ChatPanelProvider.viewType, - webview: { - options: { enableScripts: false }, - html: "", - cspSource: "", - postMessage: vi.fn().mockResolvedValue(true), - onDidReceiveMessage: vi.fn((h) => { - handler = h; - return { dispose: vi.fn() }; - }), - asWebviewUri: vi.fn((uri: vscode.Uri) => uri), - }, - title: undefined, - description: undefined, - badge: undefined, - visible: true, - show: vi.fn(), - onDidChangeVisibility: vi.fn(() => ({ dispose: vi.fn() })), - onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), - }; - - provider.resolveWebviewView( - webview, - {} as vscode.WebviewViewResolveContext, - {} as vscode.CancellationToken, - ); - - const postMessage = webview.webview.postMessage as ReturnType; - - return { - provider, - postMessage, - sendFromWebview: (msg: unknown) => handler?.(msg), - html: () => webview.webview.html, - }; -} - -function createHarness(): Harness { - const client = new MockCoderApi(); - client.setCredentials("https://coder.example.com", "test-token"); - return createHarnessFor(client); -} - -function findPostedMessage( - postMessage: ReturnType, - type: string, -): unknown { - return postMessage.mock.calls - .map((c: unknown[]) => c[0]) - .find( - (m: unknown) => - typeof m === "object" && - m !== null && - (m as { type?: string }).type === type, - ); -} - -describe("ChatPanelProvider", () => { - beforeEach(() => { - vi.resetAllMocks(); - windowMock.__setActiveColorThemeKind(vscode.ColorThemeKind.Dark); - }); - - describe("theme sync", () => { - it.each([ - [vscode.ColorThemeKind.Dark, "dark"], - [vscode.ColorThemeKind.Light, "light"], - [vscode.ColorThemeKind.HighContrast, "dark"], - [vscode.ColorThemeKind.HighContrastLight, "light"], - ])("maps ColorThemeKind %i to %s on chat-ready", (kind, expected) => { - windowMock.__setActiveColorThemeKind(kind); - const { sendFromWebview, postMessage } = createHarness(); - - sendFromWebview({ method: "coder:chat-ready" }); - - expect(findPostedMessage(postMessage, "coder:set-theme")).toEqual({ - type: "coder:set-theme", - data: { theme: expected }, - }); - }); - - it("sends theme when VS Code theme changes", () => { - const { postMessage } = createHarness(); - postMessage.mockClear(); - - windowMock.__setActiveColorThemeKind(vscode.ColorThemeKind.Light); - - expect(postMessage).toHaveBeenCalledWith({ - type: "coder:set-theme", - data: { theme: "light" }, - }); - }); - }); - - describe("auth flow", () => { - it("sends auth token on coder:vscode-ready", () => { - const { sendFromWebview, postMessage } = createHarness(); - - sendFromWebview({ method: "coder:vscode-ready" }); - - expect( - findPostedMessage(postMessage, "coder:auth-bootstrap-token"), - ).toEqual({ - type: "coder:auth-bootstrap-token", - data: { token: "test-token" }, - }); - }); - - it("posts auth-error after exhausting retries when token is missing", () => { - vi.useFakeTimers(); - try { - const client = new MockCoderApi(); - client.setCredentials("https://coder.example.com", undefined); - const { sendFromWebview, postMessage } = createHarnessFor(client); - - sendFromWebview({ method: "coder:vscode-ready" }); - // 5 retries with base 500ms exponential backoff. - vi.advanceTimersByTime(500 + 1000 + 2000 + 4000 + 8000); - - expect(findPostedMessage(postMessage, "coder:auth-error")).toEqual({ - type: "coder:auth-error", - data: { - error: "No session token available. Please sign in and retry.", - }, - }); - } finally { - vi.useRealTimers(); - } - }); - }); - - describe("navigation", () => { - it("opens external URL on coder:navigate", () => { - const { sendFromWebview } = createHarness(); - - sendFromWebview({ - method: "coder:navigate", - params: { url: "/templates" }, - }); - - expect(vscode.env.openExternal).toHaveBeenCalledWith( - vscode.Uri.parse("https://coder.example.com/templates"), - ); - }); - - it("ignores navigate without url payload", () => { - const { sendFromWebview } = createHarness(); - - sendFromWebview({ method: "coder:navigate", params: {} }); - - expect(vscode.env.openExternal).not.toHaveBeenCalled(); - }); - - it("blocks cross-origin navigate URLs", () => { - const { sendFromWebview } = createHarness(); - - sendFromWebview({ - method: "coder:navigate", - params: { url: "https://evil.com/steal" }, - }); - - expect(vscode.env.openExternal).not.toHaveBeenCalled(); - }); - }); - - describe("openChat", () => { - it("renders embed iframe for the given chat ID", () => { - const { provider, html } = createHarness(); - - provider.openChat("test-agent-123"); - - expect(html()).toContain( - "https://coder.example.com/agents/test-agent-123/embed", - ); - expect(html()).toContain("/dist/webviews/chat/index.js"); - }); - - it("focuses the chat panel", () => { - const { provider } = createHarness(); - - provider.openChat("test-agent-123"); - - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - "coder.chatPanel.focus", - ); - }); - - it("shows placeholder when no chat ID is set", () => { - const { html } = createHarness(); - - expect(html()).toContain("No active chat session"); - }); - }); - - describe("message filtering", () => { - it("ignores non-object messages", () => { - const { sendFromWebview, postMessage } = createHarness(); - - sendFromWebview(null); - sendFromWebview("string"); - sendFromWebview(42); - - expect(findPostedMessage(postMessage, "coder:set-theme")).toBeUndefined(); - }); - }); -}); diff --git a/test/webview/chat/index.test.ts b/test/webview/chat/index.test.ts deleted file mode 100644 index 935be7c1e3..0000000000 --- a/test/webview/chat/index.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import { main } from "../../../packages/chat/src/index"; -import { qs } from "../helpers"; - -const postToExtension = vi.fn(); -const postToIframe = vi.fn(); - -const EMBED_URL = "https://coder.example.com/agents/abc/embed?theme=dark"; -const ALLOWED_ORIGIN = "https://coder.example.com"; - -let iframe: HTMLIFrameElement; - -beforeAll(() => { - vi.stubGlobal( - "acquireVsCodeApi", - vi.fn(() => ({ - postMessage: postToExtension, - getState: vi.fn(), - setState: vi.fn(), - })), - ); - document.body.innerHTML = ` -
Loading chat…
- - `; - iframe = qs(document, "#chat-frame"); - // Spy on jsdom's real contentWindow.postMessage to avoid fabricating a Window. - vi.spyOn(iframe.contentWindow!, "postMessage").mockImplementation( - postToIframe, - ); - main(); -}); - -afterAll(() => { - vi.unstubAllGlobals(); -}); - -beforeEach(() => { - postToExtension.mockClear(); - postToIframe.mockClear(); -}); - -function fireMessage(data: unknown, fromIframe = false): void { - window.dispatchEvent( - new MessageEvent("message", { - data, - source: fromIframe ? iframe.contentWindow : null, - }), - ); -} - -describe("chat shim", () => { - it("forwards iframe coder:vscode-ready as ChatApi.vscodeReady", () => { - fireMessage({ type: "coder:vscode-ready" }, true); - expect(postToExtension).toHaveBeenCalledWith({ - method: "coder:vscode-ready", - }); - }); - - it("forwards iframe coder:chat-ready as ChatApi.chatReady", () => { - fireMessage({ type: "coder:chat-ready" }, true); - expect(postToExtension).toHaveBeenCalledWith({ - method: "coder:chat-ready", - }); - }); - - it("ignores iframe coder:navigate without a url payload", () => { - fireMessage({ type: "coder:navigate", payload: {} }, true); - expect(postToExtension).not.toHaveBeenCalled(); - }); - - it("forwards iframe coder:navigate with a url payload", () => { - fireMessage( - { type: "coder:navigate", payload: { url: "/templates" } }, - true, - ); - expect(postToExtension).toHaveBeenCalledWith({ - method: "coder:navigate", - params: { url: "/templates" }, - }); - }); - - it("forwards extension setTheme into the iframe", () => { - fireMessage({ type: "coder:set-theme", data: { theme: "light" } }); - expect(postToIframe).toHaveBeenCalledWith( - { type: "coder:set-theme", payload: { theme: "light" } }, - ALLOWED_ORIGIN, - ); - }); - - it("does not dispatch notification handlers for messages from the iframe", () => { - // Source-isolation: a notification-typed iframe message must not reach - // the typed handler (would destructure undefined and throw). - expect(() => - fireMessage({ type: "coder:set-theme", payload: {} }, true), - ).not.toThrow(); - expect(postToIframe).not.toHaveBeenCalled(); - }); - - it("renders a Retry button on auth-error and re-sends vscodeReady", () => { - fireMessage({ type: "coder:auth-error", data: { error: "no token" } }); - const btn = qs(document, "#retry-btn"); - btn.click(); - expect(postToExtension).toHaveBeenCalledWith({ - method: "coder:vscode-ready", - }); - }); -});