From d1ff81d2dd401f616b8a4488dca7445323046b50 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 25 Mar 2026 16:46:07 +0300 Subject: [PATCH 1/3] feat: add theme sync, scroll-to-bottom, and navigation to chat embed Support the Coder embed postMessage protocol for theme sync, scroll-to-bottom, and navigation handling. The extension detects the VS Code color theme and forwards it to the embedded iframe via coder:set-theme, sends coder:scroll-to-bottom on chat-ready, and opens coder:navigate URLs externally. Also adds the coder.chat.refresh command, TTL expiry for pending memento values, and eager agentsEnabled context for pending chats. --- package.json | 17 +- src/core/mementoManager.ts | 37 ++- src/extension.ts | 5 + src/webviews/chat/chatPanelProvider.ts | 79 +++++- src/webviews/util.ts | 2 +- test/mocks/testHelpers.ts | 2 + test/mocks/vscode.runtime.ts | 12 + test/unit/core/mementoManager.test.ts | 47 +++- .../webviews/chat/chatPanelProvider.test.ts | 236 ++++++++++++++++++ 9 files changed, 416 insertions(+), 21 deletions(-) create mode 100644 test/unit/webviews/chat/chatPanelProvider.test.ts diff --git a/package.json b/package.json index 618611c5..dd77bc6b 100644 --- a/package.json +++ b/package.json @@ -353,6 +353,12 @@ "category": "Coder", "icon": "$(refresh)" }, + { + "command": "coder.chat.refresh", + "title": "Refresh Chat", + "category": "Coder", + "icon": "$(refresh)" + }, { "command": "coder.applyRecommendedSettings", "title": "Apply Recommended SSH Settings", @@ -424,6 +430,10 @@ "command": "coder.tasks.refresh", "when": "false" }, + { + "command": "coder.chat.refresh", + "when": "false" + }, { "command": "coder.applyRecommendedSettings" } @@ -465,6 +475,11 @@ "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": [ @@ -579,7 +594,7 @@ "extensionPack": [ "ms-vscode-remote.remote-ssh" ], - "packageManager": "pnpm@10.32.1", + "packageManager": "pnpm@10.33.0", "engines": { "vscode": "^1.106.0", "node": ">= 22" diff --git a/src/core/mementoManager.ts b/src/core/mementoManager.ts index b3b9c6df..4a83cdb1 100644 --- a/src/core/mementoManager.ts +++ b/src/core/mementoManager.ts @@ -3,6 +3,15 @@ import type { Memento } from "vscode"; // Maximum number of recent URLs to store. const MAX_URLS = 10; +// Pending values expire after this duration to guard against stale +// state from crashes or interrupted reloads. +const PENDING_TTL_MS = 5 * 60 * 1000; + +interface Stamped { + value: T; + setAt: number; +} + export class MementoManager { constructor(private readonly memento: Memento) {} @@ -42,7 +51,7 @@ export class MementoManager { * the workspace startup confirmation is shown to the user. */ public async setFirstConnect(): Promise { - return this.memento.update("firstConnect", true); + return this.setStamped("firstConnect", true); } /** @@ -51,21 +60,21 @@ export class MementoManager { * prompting the user for confirmation. */ public async getAndClearFirstConnect(): Promise { - const isFirst = this.memento.get("firstConnect"); - if (isFirst !== undefined) { + const value = this.getStamped("firstConnect"); + if (value !== undefined) { await this.memento.update("firstConnect", undefined); } - return isFirst === true; + return value === true; } /** Store a chat ID to open after a remote-authority reload. */ public async setPendingChatId(chatId: string): Promise { - await this.memento.update("pendingChatId", chatId); + await this.setStamped("pendingChatId", chatId); } /** Read and clear the pending chat ID (undefined if none). */ public async getAndClearPendingChatId(): Promise { - const chatId = this.memento.get("pendingChatId"); + const chatId = this.getStamped("pendingChatId"); if (chatId !== undefined) { await this.memento.update("pendingChatId", undefined); } @@ -76,4 +85,20 @@ export class MementoManager { 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() }); + } + + private getStamped(key: string): T | undefined { + const raw = this.memento.get>(key); + if (raw?.setAt !== undefined && Date.now() - raw.setAt <= PENDING_TTL_MS) { + return raw.value; + } + // Expired or legacy, clean up. + if (raw !== undefined) { + void this.memento.update(key, undefined); + } + return undefined; + } } diff --git a/src/extension.ts b/src/extension.ts index df2ecd6a..485c833d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -232,6 +232,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { chatPanelProvider, { webviewOptions: { retainContextWhenHidden: true } }, ), + vscode.commands.registerCommand("coder.chat.refresh", () => + chatPanelProvider.refresh(), + ), ); ctx.subscriptions.push( @@ -333,6 +336,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // 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); } } diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index 5a44dd90..a094cf32 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -1,9 +1,8 @@ -import { randomBytes } from "node:crypto"; +import * as vscode from "vscode"; import { type CoderApi } from "../../api/coderApi"; import { type Logger } from "../../logging/logger"; - -import type * as vscode from "vscode"; +import { getNonce } from "../util"; /** * Provides a webview that embeds the Coder agent chat UI. @@ -34,15 +33,38 @@ export class ChatPanelProvider 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 sendScrollToBottom(): void { + this.view?.webview.postMessage({ type: "coder:scroll-to-bottom" }); + } + + private sendTheme(): void { + this.view?.webview.postMessage({ + type: "coder:set-theme", + 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; this.refresh(); - this.view?.show(true); + void vscode.commands.executeCommand(`${ChatPanelProvider.viewType}.focus`); } resolveWebviewView( @@ -56,9 +78,12 @@ export class ChatPanelProvider webviewView.webview.onDidReceiveMessage((msg: unknown) => { this.handleMessage(msg); }), + vscode.window.onDidChangeActiveColorTheme(() => { + this.sendTheme(); + }), ); this.renderView(); - webviewView.onDidDispose(() => this.dispose()); + this.disposables.push(webviewView.onDidDispose(() => this.dispose())); } public refresh(): void { @@ -85,7 +110,7 @@ export class ChatPanelProvider return; } - const embedUrl = `${coderUrl}/agents/${this.chatId}/embed`; + const embedUrl = `${coderUrl}/agents/${this.chatId}/embed?theme=${this.getTheme()}`; webview.html = this.getIframeHtml(embedUrl, coderUrl); } @@ -93,9 +118,25 @@ export class ChatPanelProvider if (typeof message !== "object" || message === null) { return; } - const msg = message as { type?: string }; - if (msg.type === "coder:vscode-ready") { - this.sendAuthToken(); + const msg = message as { type?: string; payload?: { url?: string } }; + switch (msg.type) { + case "coder:vscode-ready": + this.sendAuthToken(); + break; + case "coder:chat-ready": + this.sendTheme(); + this.sendScrollToBottom(); + break; + case "coder:navigate": { + const url = msg.payload?.url; + const coderUrl = this.client.getHost(); + if (url && coderUrl) { + void vscode.env.openExternal(vscode.Uri.parse(coderUrl + url)); + } + break; + } + default: + break; } } @@ -142,7 +183,7 @@ export class ChatPanelProvider } private getIframeHtml(embedUrl: string, allowedOrigin: string): string { - const nonce = randomBytes(16).toString("base64"); + const nonce = getNonce(); return /* html */ ` @@ -205,6 +246,12 @@ export class ChatPanelProvider status.textContent = 'Authenticating…'; vscode.postMessage({ type: 'coder:vscode-ready' }); } + if (data.type === 'coder:chat-ready') { + vscode.postMessage({ type: 'coder:chat-ready' }); + } + if (data.type === 'coder:navigate') { + vscode.postMessage(data); + } return; } @@ -216,6 +263,18 @@ export class ChatPanelProvider }, '${allowedOrigin}'); } + if (data.type === 'coder:set-theme') { + iframe.contentWindow.postMessage({ + type: 'coder:set-theme', + payload: { theme: data.theme }, + }, '${allowedOrigin}'); + } + + if (data.type === 'coder:scroll-to-bottom') { + iframe.contentWindow.postMessage( + { type: 'coder:scroll-to-bottom' }, '${allowedOrigin}'); + } + if (data.type === 'coder:auth-error') { status.textContent = ''; status.appendChild(document.createTextNode(data.error || 'Authentication failed.')); diff --git a/src/webviews/util.ts b/src/webviews/util.ts index ca7be033..edb73fb9 100644 --- a/src/webviews/util.ts +++ b/src/webviews/util.ts @@ -42,6 +42,6 @@ export function getWebviewHtml( `; } -function getNonce(): string { +export function getNonce(): string { return randomBytes(16).toString("base64"); } diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 659f5d71..929e1bc5 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -509,6 +509,7 @@ export class MockCoderApi implements Pick< | "setSessionToken" | "setCredentials" | "getHost" + | "getSessionToken" | "getAuthenticatedUser" | "dispose" | "getExperiments" @@ -534,6 +535,7 @@ export class MockCoderApi implements Pick< ); readonly getHost = vi.fn(() => this._host); + readonly getSessionToken = vi.fn(() => this._token); readonly getAuthenticatedUser = vi.fn((): Promise => { if (this.authenticatedUser instanceof Error) { diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 76a906af..5cf6a98b 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -26,6 +26,12 @@ export const TreeItemCollapsibleState = E({ Expanded: 2, }); export const StatusBarAlignment = E({ Left: 1, Right: 2 }); +export const ColorThemeKind = E({ + Light: 1, + Dark: 2, + HighContrast: 3, + HighContrastLight: 4, +}); export const ExtensionMode = E({ Production: 1, Development: 2, Test: 3 }); export const UIKind = E({ Desktop: 1, Web: 2 }); export const InputBoxValidationSeverity = E({ @@ -82,8 +88,13 @@ export class EventEmitter { const onDidChangeConfiguration = new EventEmitter(); const onDidChangeWorkspaceFolders = new EventEmitter(); +const onDidChangeActiveColorTheme = new EventEmitter(); export const window = { + activeColorTheme: { kind: ColorThemeKind.Dark } as { kind: number }, + onDidChangeActiveColorTheme: onDidChangeActiveColorTheme.event, + __fireDidChangeActiveColorTheme: (e: unknown) => + onDidChangeActiveColorTheme.fire(e), showInformationMessage: vi.fn(), showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), @@ -151,6 +162,7 @@ const vscode = { ConfigurationTarget, TreeItemCollapsibleState, StatusBarAlignment, + ColorThemeKind, ExtensionMode, UIKind, InputBoxValidationSeverity, diff --git a/test/unit/core/mementoManager.test.ts b/test/unit/core/mementoManager.test.ts index 791f7602..16c3efbe 100644 --- a/test/unit/core/mementoManager.test.ts +++ b/test/unit/core/mementoManager.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MementoManager } from "@/core/mementoManager"; @@ -9,10 +9,15 @@ describe("MementoManager", () => { let mementoManager: MementoManager; beforeEach(() => { + vi.useFakeTimers(); memento = new InMemoryMemento(); mementoManager = new MementoManager(memento); }); + afterEach(() => { + vi.useRealTimers(); + }); + describe("addToUrlHistory", () => { it("should add URL to history", async () => { await mementoManager.addToUrlHistory("https://coder.example.com"); @@ -69,9 +74,45 @@ describe("MementoManager", () => { expect(await mementoManager.getAndClearFirstConnect()).toBe(false); }); - it("should return false for non-boolean values", async () => { - await memento.update("firstConnect", "truthy-string"); + it("should treat legacy bare values as expired", async () => { + await memento.update("firstConnect", true); expect(await mementoManager.getAndClearFirstConnect()).toBe(false); }); + + it("should expire after 5 minutes", async () => { + await mementoManager.setFirstConnect(); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + expect(await mementoManager.getAndClearFirstConnect()).toBe(false); + }); + }); + + 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/webviews/chat/chatPanelProvider.test.ts b/test/unit/webviews/chat/chatPanelProvider.test.ts new file mode 100644 index 00000000..d51fdcdd --- /dev/null +++ b/test/unit/webviews/chat/chatPanelProvider.test.ts @@ -0,0 +1,236 @@ +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"; + +import type { CoderApi } from "@/api/coderApi"; + +type WindowMock = typeof vscode.window & { + activeColorTheme: { kind: number }; + __fireDidChangeActiveColorTheme: (e: unknown) => void; +}; + +function setMockTheme(kind: number): void { + (vscode.window as WindowMock).activeColorTheme = { kind }; +} + +function findMessage(messages: unknown[], type: string): unknown { + return messages.find( + (m) => + typeof m === "object" && + m !== null && + (m as { type?: string }).type === type, + ); +} + +interface Harness { + provider: ChatPanelProvider; + client: MockCoderApi; + postMessage: ReturnType; + sendFromWebview: (msg: unknown) => void; + messages: () => unknown[]; +} + +function createHarness(): Harness { + const client = new MockCoderApi(); + client.setCredentials("https://coder.example.com", "test-token"); + + const provider = new ChatPanelProvider( + client as unknown as CoderApi, + createMockLogger(), + ); + + const posted: unknown[] = []; + let handler: ((msg: unknown) => void) | null = null; + + const webview: vscode.WebviewView = { + viewType: ChatPanelProvider.viewType, + webview: { + options: { enableScripts: false }, + html: "", + cspSource: "", + postMessage: vi.fn((msg: unknown) => { + posted.push(msg); + return Promise.resolve(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, + ); + + return { + provider, + client, + postMessage: webview.webview.postMessage as ReturnType, + sendFromWebview: (msg: unknown) => handler?.(msg), + messages: () => [...posted], + }; +} + +describe("ChatPanelProvider", () => { + beforeEach(() => { + vi.resetAllMocks(); + setMockTheme(vscode.ColorThemeKind.Dark); + }); + + describe("theme sync", () => { + it("sends dark theme on coder:chat-ready", () => { + const { sendFromWebview, messages } = createHarness(); + + sendFromWebview({ type: "coder:chat-ready" }); + + expect(findMessage(messages(), "coder:set-theme")).toEqual({ + type: "coder:set-theme", + theme: "dark", + }); + }); + + it("sends scroll-to-bottom on coder:chat-ready", () => { + const { sendFromWebview, messages } = createHarness(); + + sendFromWebview({ type: "coder:chat-ready" }); + + expect(findMessage(messages(), "coder:scroll-to-bottom")).toEqual({ + type: "coder:scroll-to-bottom", + }); + }); + + it("sends light theme on coder:chat-ready", () => { + setMockTheme(vscode.ColorThemeKind.Light); + const { sendFromWebview, messages } = createHarness(); + + sendFromWebview({ type: "coder:chat-ready" }); + + expect(findMessage(messages(), "coder:set-theme")).toEqual({ + type: "coder:set-theme", + theme: "light", + }); + }); + + it("detects HighContrastLight as light theme", () => { + setMockTheme(vscode.ColorThemeKind.HighContrastLight); + const { sendFromWebview, messages } = createHarness(); + + sendFromWebview({ type: "coder:chat-ready" }); + + expect(findMessage(messages(), "coder:set-theme")).toEqual({ + type: "coder:set-theme", + theme: "light", + }); + }); + + it("detects HighContrast as dark theme", () => { + setMockTheme(vscode.ColorThemeKind.HighContrast); + const { sendFromWebview, messages } = createHarness(); + + sendFromWebview({ type: "coder:chat-ready" }); + + expect(findMessage(messages(), "coder:set-theme")).toEqual({ + type: "coder:set-theme", + theme: "dark", + }); + }); + + it("sends theme when VS Code theme changes", () => { + const { postMessage } = createHarness(); + postMessage.mockClear(); + + setMockTheme(vscode.ColorThemeKind.Light); + (vscode.window as WindowMock).__fireDidChangeActiveColorTheme({ + kind: vscode.ColorThemeKind.Light, + }); + + expect(postMessage).toHaveBeenCalledWith({ + type: "coder:set-theme", + theme: "light", + }); + }); + }); + + describe("auth flow", () => { + it("sends auth token on coder:vscode-ready", () => { + const { sendFromWebview, messages } = createHarness(); + + sendFromWebview({ type: "coder:vscode-ready" }); + + expect(findMessage(messages(), "coder:auth-bootstrap-token")).toEqual({ + type: "coder:auth-bootstrap-token", + token: "test-token", + }); + }); + }); + + describe("navigation", () => { + it("opens external URL on coder:navigate", () => { + const { sendFromWebview } = createHarness(); + + sendFromWebview({ + type: "coder:navigate", + payload: { 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({ type: "coder:navigate" }); + + expect(vscode.env.openExternal).not.toHaveBeenCalled(); + }); + }); + + describe("iframe HTML", () => { + it("generates HTML with iframe when chat ID is set", () => { + const { provider } = createHarness(); + + provider.openChat("test-agent-123"); + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + "coder.chatPanel.focus", + ); + }); + + it("shows no-agent message when no chat ID is set", () => { + const harness = createHarness(); + + const webview = ( + harness.provider as unknown as { view: vscode.WebviewView } + ).view; + expect(webview.webview.html).toContain("No active chat session"); + }); + }); + + describe("message filtering", () => { + it("ignores non-object messages", () => { + const { sendFromWebview, messages } = createHarness(); + + sendFromWebview(null); + sendFromWebview("string"); + sendFromWebview(42); + + expect(findMessage(messages(), "coder:set-theme")).toBeUndefined(); + }); + }); +}); From 6056f2b5bc47332decb2d754cbbd89b3f6147477 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 25 Mar 2026 18:01:15 +0300 Subject: [PATCH 2/3] fix: open chat directly when workspace is already open via deeplink When a deeplink with chatId targets an already-open workspace, VS Code refocuses without reloading so activate() never consumes the pending chatId. Call openChat directly after commands.open() as a fallback. Also refactors registerUriHandler to accept a single deps object and simplifies chatPanelProvider tests. --- src/extension.ts | 7 +- src/uri/uriHandler.ts | 59 ++++---- test/unit/uri/uriHandler.test.ts | 38 +++++- .../webviews/chat/chatPanelProvider.test.ts | 128 ++++++++---------- 4 files changed, 121 insertions(+), 111 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 485c833d..7f84ff08 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -238,7 +238,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); ctx.subscriptions.push( - registerUriHandler(serviceContainer, deploymentManager, commands), + registerUriHandler({ + serviceContainer, + deploymentManager, + commands, + chatPanelProvider, + }), vscode.commands.registerCommand( "coder.login", commands.login.bind(commands), diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index 6902c37f..c700e3da 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -1,19 +1,25 @@ import * as vscode from "vscode"; import { errToStr } from "../api/api-helper"; -import { type Commands } from "../commands"; -import { type ServiceContainer } from "../core/container"; -import { type DeploymentManager } from "../deployment/deploymentManager"; import { CALLBACK_PATH } from "../oauth/utils"; import { maybeAskUrl } from "../promptUtils"; import { toSafeHost } from "../util"; import { vscodeProposed } from "../vscodeProposed"; -interface UriRouteContext { - params: URLSearchParams; +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: DeploymentManager; commands: Commands; + chatPanelProvider: ChatPanelProvider; +} + +interface UriRouteContext extends UriHandlerDeps { + params: URLSearchParams; } type UriRouteHandler = (ctx: UriRouteContext) => Promise; @@ -27,17 +33,20 @@ const routes: Readonly> = { /** * Registers the URI handler for `{vscode.env.uriScheme}://coder.coder-remote`... URIs. */ -export function registerUriHandler( - serviceContainer: ServiceContainer, - deploymentManager: DeploymentManager, - commands: Commands, -): vscode.Disposable { - const output = serviceContainer.getLogger(); +export function registerUriHandler(deps: UriHandlerDeps): vscode.Disposable { + const output = deps.serviceContainer.getLogger(); return vscode.window.registerUriHandler({ handleUri: async (uri) => { try { - await routeUri(uri, serviceContainer, deploymentManager, commands); + const handler = routes[uri.path]; + if (!handler) { + throw new Error(`Unknown path ${uri.path}`); + } + await handler({ + ...deps, + params: new URLSearchParams(uri.query), + }); } catch (error) { const message = errToStr(error, "No error message was provided"); output.warn(`Failed to handle URI ${uri.toString()}: ${message}`); @@ -51,25 +60,6 @@ export function registerUriHandler( }); } -async function routeUri( - uri: vscode.Uri, - serviceContainer: ServiceContainer, - deploymentManager: DeploymentManager, - commands: Commands, -): Promise { - const handler = routes[uri.path]; - if (!handler) { - throw new Error(`Unknown path ${uri.path}`); - } - - await handler({ - params: new URLSearchParams(uri.query), - serviceContainer, - deploymentManager, - commands, - }); -} - function getRequiredParam(params: URLSearchParams, name: string): string { const value = params.get(name); if (!value) { @@ -116,6 +106,13 @@ async function handleOpen(ctx: UriRouteContext): Promise { await mementoManager.clearPendingChatId(); } } + + // When the workspace is already open VS Code refocuses without + // reloading, so activate() won't consume the pending chatId. + if (opened && chatId) { + serviceContainer.getContextManager().set("coder.agentsEnabled", true); + ctx.chatPanelProvider.openChat(chatId); + } } async function handleOpenDevContainer(ctx: UriRouteContext): Promise { diff --git a/test/unit/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts index b5e64925..bdca0c95 100644 --- a/test/unit/uri/uriHandler.test.ts +++ b/test/unit/uri/uriHandler.test.ts @@ -12,12 +12,14 @@ import { createMockUser, InMemoryMemento, InMemorySecretStorage, + MockContextManager, } from "../../mocks/testHelpers"; import type { Commands } from "@/commands"; import type { ServiceContainer } from "@/core/container"; import type { DeploymentManager } from "@/deployment/deploymentManager"; import type { LoginCoordinator, LoginOptions } from "@/login/loginCoordinator"; +import type { ChatPanelProvider } from "@/webviews/chat/chatPanelProvider"; vi.mock("@/promptUtils", () => ({ maybeAskUrl: vi.fn() })); @@ -77,6 +79,7 @@ function createTestContext() { getSecretsManager: () => secretsManager, getMementoManager: () => mementoManager, getLoginCoordinator: () => loginCoordinator as unknown as LoginCoordinator, + getContextManager: () => new MockContextManager(), getLogger: () => logger, } as unknown as ServiceContainer; @@ -94,11 +97,16 @@ function createTestContext() { .mocked(vscode.window.showErrorMessage) .mockResolvedValue(undefined); - registerUriHandler( - container, - deploymentManager as unknown as DeploymentManager, - commands as unknown as Commands, - ); + const chatPanelProvider = { + openChat: vi.fn(), + } as unknown as ChatPanelProvider; + + registerUriHandler({ + serviceContainer: container, + deploymentManager: deploymentManager as unknown as DeploymentManager, + commands: commands as unknown as Commands, + chatPanelProvider, + }); return { commands, @@ -107,6 +115,7 @@ function createTestContext() { secretsManager, logger, showErrorMessage, + chatPanelProvider, handleUri: registeredHandler!, }; } @@ -150,6 +159,25 @@ describe("uriHandler", () => { expected, ); }); + + 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)}`; + await handleUri(createMockUri("/open", query)); + expect(chatPanelProvider.openChat).toHaveBeenCalledWith("chat-123"); + }); + + 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)}`; + await handleUri(createMockUri("/open", query)); + expect(chatPanelProvider.openChat).not.toHaveBeenCalled(); + }); }); describe("/openDevContainer", () => { diff --git a/test/unit/webviews/chat/chatPanelProvider.test.ts b/test/unit/webviews/chat/chatPanelProvider.test.ts index d51fdcdd..49b53735 100644 --- a/test/unit/webviews/chat/chatPanelProvider.test.ts +++ b/test/unit/webviews/chat/chatPanelProvider.test.ts @@ -16,21 +16,11 @@ function setMockTheme(kind: number): void { (vscode.window as WindowMock).activeColorTheme = { kind }; } -function findMessage(messages: unknown[], type: string): unknown { - return messages.find( - (m) => - typeof m === "object" && - m !== null && - (m as { type?: string }).type === type, - ); -} - interface Harness { provider: ChatPanelProvider; - client: MockCoderApi; postMessage: ReturnType; sendFromWebview: (msg: unknown) => void; - messages: () => unknown[]; + html: () => string; } function createHarness(): Harness { @@ -42,7 +32,6 @@ function createHarness(): Harness { createMockLogger(), ); - const posted: unknown[] = []; let handler: ((msg: unknown) => void) | null = null; const webview: vscode.WebviewView = { @@ -51,10 +40,7 @@ function createHarness(): Harness { options: { enableScripts: false }, html: "", cspSource: "", - postMessage: vi.fn((msg: unknown) => { - posted.push(msg); - return Promise.resolve(true); - }), + postMessage: vi.fn().mockResolvedValue(true), onDidReceiveMessage: vi.fn((h) => { handler = h; return { dispose: vi.fn() }; @@ -76,15 +62,30 @@ function createHarness(): Harness { {} as vscode.CancellationToken, ); + const postMessage = webview.webview.postMessage as ReturnType; + return { provider, - client, - postMessage: webview.webview.postMessage as ReturnType, + postMessage, sendFromWebview: (msg: unknown) => handler?.(msg), - messages: () => [...posted], + html: () => webview.webview.html, }; } +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(); @@ -92,63 +93,33 @@ describe("ChatPanelProvider", () => { }); describe("theme sync", () => { - it("sends dark theme on coder:chat-ready", () => { - const { sendFromWebview, messages } = createHarness(); + 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) => { + setMockTheme(kind); + const { sendFromWebview, postMessage } = createHarness(); sendFromWebview({ type: "coder:chat-ready" }); - expect(findMessage(messages(), "coder:set-theme")).toEqual({ + expect(findPostedMessage(postMessage, "coder:set-theme")).toEqual({ type: "coder:set-theme", - theme: "dark", + theme: expected, }); }); - it("sends scroll-to-bottom on coder:chat-ready", () => { - const { sendFromWebview, messages } = createHarness(); + it("sends scroll-to-bottom on chat-ready", () => { + const { sendFromWebview, postMessage } = createHarness(); sendFromWebview({ type: "coder:chat-ready" }); - expect(findMessage(messages(), "coder:scroll-to-bottom")).toEqual({ + expect(findPostedMessage(postMessage, "coder:scroll-to-bottom")).toEqual({ type: "coder:scroll-to-bottom", }); }); - it("sends light theme on coder:chat-ready", () => { - setMockTheme(vscode.ColorThemeKind.Light); - const { sendFromWebview, messages } = createHarness(); - - sendFromWebview({ type: "coder:chat-ready" }); - - expect(findMessage(messages(), "coder:set-theme")).toEqual({ - type: "coder:set-theme", - theme: "light", - }); - }); - - it("detects HighContrastLight as light theme", () => { - setMockTheme(vscode.ColorThemeKind.HighContrastLight); - const { sendFromWebview, messages } = createHarness(); - - sendFromWebview({ type: "coder:chat-ready" }); - - expect(findMessage(messages(), "coder:set-theme")).toEqual({ - type: "coder:set-theme", - theme: "light", - }); - }); - - it("detects HighContrast as dark theme", () => { - setMockTheme(vscode.ColorThemeKind.HighContrast); - const { sendFromWebview, messages } = createHarness(); - - sendFromWebview({ type: "coder:chat-ready" }); - - expect(findMessage(messages(), "coder:set-theme")).toEqual({ - type: "coder:set-theme", - theme: "dark", - }); - }); - it("sends theme when VS Code theme changes", () => { const { postMessage } = createHarness(); postMessage.mockClear(); @@ -167,11 +138,13 @@ describe("ChatPanelProvider", () => { describe("auth flow", () => { it("sends auth token on coder:vscode-ready", () => { - const { sendFromWebview, messages } = createHarness(); + const { sendFromWebview, postMessage } = createHarness(); sendFromWebview({ type: "coder:vscode-ready" }); - expect(findMessage(messages(), "coder:auth-bootstrap-token")).toEqual({ + expect( + findPostedMessage(postMessage, "coder:auth-bootstrap-token"), + ).toEqual({ type: "coder:auth-bootstrap-token", token: "test-token", }); @@ -201,8 +174,18 @@ describe("ChatPanelProvider", () => { }); }); - describe("iframe HTML", () => { - it("generates HTML with iframe when chat ID is set", () => { + 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", + ); + }); + + it("focuses the chat panel", () => { const { provider } = createHarness(); provider.openChat("test-agent-123"); @@ -212,25 +195,22 @@ describe("ChatPanelProvider", () => { ); }); - it("shows no-agent message when no chat ID is set", () => { - const harness = createHarness(); + it("shows placeholder when no chat ID is set", () => { + const { html } = createHarness(); - const webview = ( - harness.provider as unknown as { view: vscode.WebviewView } - ).view; - expect(webview.webview.html).toContain("No active chat session"); + expect(html()).toContain("No active chat session"); }); }); describe("message filtering", () => { it("ignores non-object messages", () => { - const { sendFromWebview, messages } = createHarness(); + const { sendFromWebview, postMessage } = createHarness(); sendFromWebview(null); sendFromWebview("string"); sendFromWebview(42); - expect(findMessage(messages(), "coder:set-theme")).toBeUndefined(); + expect(findPostedMessage(postMessage, "coder:set-theme")).toBeUndefined(); }); }); }); From b7d2fbf8420dcd799a2886ed23bb715b9b3a6987 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 25 Mar 2026 19:14:13 +0300 Subject: [PATCH 3/3] handle review comments --- src/uri/uriHandler.ts | 12 +++---- src/webviews/chat/chatPanelProvider.ts | 26 +++++++++++--- test/mocks/vscode.runtime.ts | 8 +++-- test/unit/uri/uriHandler.test.ts | 11 ++---- .../webviews/chat/chatPanelProvider.test.ts | 36 +++++++++---------- 5 files changed, 53 insertions(+), 40 deletions(-) diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index c700e3da..f03e2690 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -13,9 +13,9 @@ import type { ChatPanelProvider } from "../webviews/chat/chatPanelProvider"; interface UriHandlerDeps { serviceContainer: ServiceContainer; - deploymentManager: DeploymentManager; - commands: Commands; - chatPanelProvider: ChatPanelProvider; + deploymentManager: Pick; + commands: Pick; + chatPanelProvider: Pick; } interface UriRouteContext extends UriHandlerDeps { @@ -107,8 +107,8 @@ async function handleOpen(ctx: UriRouteContext): Promise { } } - // When the workspace is already open VS Code refocuses without - // reloading, so activate() won't consume the pending chatId. + // 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); @@ -152,7 +152,7 @@ async function handleOpenDevContainer(ctx: UriRouteContext): Promise { async function setupDeployment( params: URLSearchParams, serviceContainer: ServiceContainer, - deploymentManager: DeploymentManager, + deploymentManager: Pick, ): Promise { const secretsManager = serviceContainer.getSecretsManager(); const mementoManager = serviceContainer.getMementoManager(); diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index a094cf32..707b6633 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -29,7 +29,7 @@ export class ChatPanelProvider private authRetryTimer: ReturnType | undefined; constructor( - private readonly client: CoderApi, + private readonly client: Pick, private readonly logger: Logger, ) {} @@ -63,6 +63,7 @@ export class ChatPanelProvider return; } this.chatId = chatId; + // No-op if unresolved; the focus command triggers resolveWebviewView(). this.refresh(); void vscode.commands.executeCommand(`${ChatPanelProvider.viewType}.focus`); } @@ -72,6 +73,9 @@ export class ChatPanelProvider _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 }; this.disposables.push( @@ -83,7 +87,7 @@ export class ChatPanelProvider }), ); this.renderView(); - this.disposables.push(webviewView.onDidDispose(() => this.dispose())); + this.disposables.push(webviewView.onDidDispose(() => this.disposeView())); } public refresh(): void { @@ -131,7 +135,17 @@ export class ChatPanelProvider const url = msg.payload?.url; const coderUrl = this.client.getHost(); if (url && coderUrl) { - void vscode.env.openExternal(vscode.Uri.parse(coderUrl + url)); + 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}`); + } } break; } @@ -307,11 +321,15 @@ text-align:center;}

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

`; } - dispose(): void { + private disposeView(): void { clearTimeout(this.authRetryTimer); for (const d of this.disposables) { d.dispose(); } this.disposables = []; } + + dispose(): void { + this.disposeView(); + } } diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 5cf6a98b..f8e3b490 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -91,10 +91,12 @@ const onDidChangeWorkspaceFolders = new EventEmitter(); const onDidChangeActiveColorTheme = new EventEmitter(); export const window = { - activeColorTheme: { kind: ColorThemeKind.Dark } as { kind: number }, + activeColorTheme: { kind: ColorThemeKind.Dark }, onDidChangeActiveColorTheme: onDidChangeActiveColorTheme.event, - __fireDidChangeActiveColorTheme: (e: unknown) => - onDidChangeActiveColorTheme.fire(e), + __setActiveColorThemeKind: (kind: number) => { + window.activeColorTheme = { kind }; + onDidChangeActiveColorTheme.fire({ kind }); + }, showInformationMessage: vi.fn(), showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), diff --git a/test/unit/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts index bdca0c95..e2671ed4 100644 --- a/test/unit/uri/uriHandler.test.ts +++ b/test/unit/uri/uriHandler.test.ts @@ -15,11 +15,8 @@ import { MockContextManager, } from "../../mocks/testHelpers"; -import type { Commands } from "@/commands"; import type { ServiceContainer } from "@/core/container"; -import type { DeploymentManager } from "@/deployment/deploymentManager"; import type { LoginCoordinator, LoginOptions } from "@/login/loginCoordinator"; -import type { ChatPanelProvider } from "@/webviews/chat/chatPanelProvider"; vi.mock("@/promptUtils", () => ({ maybeAskUrl: vi.fn() })); @@ -97,14 +94,12 @@ function createTestContext() { .mocked(vscode.window.showErrorMessage) .mockResolvedValue(undefined); - const chatPanelProvider = { - openChat: vi.fn(), - } as unknown as ChatPanelProvider; + const chatPanelProvider = { openChat: vi.fn() }; registerUriHandler({ serviceContainer: container, - deploymentManager: deploymentManager as unknown as DeploymentManager, - commands: commands as unknown as Commands, + deploymentManager, + commands, chatPanelProvider, }); diff --git a/test/unit/webviews/chat/chatPanelProvider.test.ts b/test/unit/webviews/chat/chatPanelProvider.test.ts index 49b53735..2b7d94d0 100644 --- a/test/unit/webviews/chat/chatPanelProvider.test.ts +++ b/test/unit/webviews/chat/chatPanelProvider.test.ts @@ -5,17 +5,10 @@ import { ChatPanelProvider } from "@/webviews/chat/chatPanelProvider"; import { createMockLogger, MockCoderApi } from "../../../mocks/testHelpers"; -import type { CoderApi } from "@/api/coderApi"; - -type WindowMock = typeof vscode.window & { - activeColorTheme: { kind: number }; - __fireDidChangeActiveColorTheme: (e: unknown) => void; +const windowMock = vscode.window as typeof vscode.window & { + __setActiveColorThemeKind: (kind: number) => void; }; -function setMockTheme(kind: number): void { - (vscode.window as WindowMock).activeColorTheme = { kind }; -} - interface Harness { provider: ChatPanelProvider; postMessage: ReturnType; @@ -27,10 +20,7 @@ function createHarness(): Harness { const client = new MockCoderApi(); client.setCredentials("https://coder.example.com", "test-token"); - const provider = new ChatPanelProvider( - client as unknown as CoderApi, - createMockLogger(), - ); + const provider = new ChatPanelProvider(client, createMockLogger()); let handler: ((msg: unknown) => void) | null = null; @@ -89,7 +79,7 @@ function findPostedMessage( describe("ChatPanelProvider", () => { beforeEach(() => { vi.resetAllMocks(); - setMockTheme(vscode.ColorThemeKind.Dark); + windowMock.__setActiveColorThemeKind(vscode.ColorThemeKind.Dark); }); describe("theme sync", () => { @@ -99,7 +89,7 @@ describe("ChatPanelProvider", () => { [vscode.ColorThemeKind.HighContrast, "dark"], [vscode.ColorThemeKind.HighContrastLight, "light"], ])("maps ColorThemeKind %i to %s on chat-ready", (kind, expected) => { - setMockTheme(kind); + windowMock.__setActiveColorThemeKind(kind); const { sendFromWebview, postMessage } = createHarness(); sendFromWebview({ type: "coder:chat-ready" }); @@ -124,10 +114,7 @@ describe("ChatPanelProvider", () => { const { postMessage } = createHarness(); postMessage.mockClear(); - setMockTheme(vscode.ColorThemeKind.Light); - (vscode.window as WindowMock).__fireDidChangeActiveColorTheme({ - kind: vscode.ColorThemeKind.Light, - }); + windowMock.__setActiveColorThemeKind(vscode.ColorThemeKind.Light); expect(postMessage).toHaveBeenCalledWith({ type: "coder:set-theme", @@ -172,6 +159,17 @@ describe("ChatPanelProvider", () => { expect(vscode.env.openExternal).not.toHaveBeenCalled(); }); + + it("blocks cross-origin navigate URLs", () => { + const { sendFromWebview } = createHarness(); + + sendFromWebview({ + type: "coder:navigate", + payload: { url: "https://evil.com/steal" }, + }); + + expect(vscode.env.openExternal).not.toHaveBeenCalled(); + }); }); describe("openChat", () => {