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..7f84ff08 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -232,10 +232,18 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { chatPanelProvider, { webviewOptions: { retainContextWhenHidden: true } }, ), + vscode.commands.registerCommand("coder.chat.refresh", () => + chatPanelProvider.refresh(), + ), ); ctx.subscriptions.push( - registerUriHandler(serviceContainer, deploymentManager, commands), + registerUriHandler({ + serviceContainer, + deploymentManager, + commands, + chatPanelProvider, + }), vscode.commands.registerCommand( "coder.login", commands.login.bind(commands), @@ -333,6 +341,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/uri/uriHandler.ts b/src/uri/uriHandler.ts index 6902c37f..f03e2690 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; + deploymentManager: Pick; + commands: Pick; + chatPanelProvider: Pick; +} + +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(); } } + + // 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); + } } async function handleOpenDevContainer(ctx: UriRouteContext): Promise { @@ -155,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 5a44dd90..707b6633 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. @@ -30,19 +29,43 @@ export class ChatPanelProvider private authRetryTimer: ReturnType | undefined; constructor( - private readonly client: CoderApi, + 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 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; + // No-op if unresolved; the focus command triggers resolveWebviewView(). this.refresh(); - this.view?.show(true); + void vscode.commands.executeCommand(`${ChatPanelProvider.viewType}.focus`); } resolveWebviewView( @@ -50,15 +73,21 @@ 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( 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.disposeView())); } public refresh(): void { @@ -85,7 +114,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 +122,35 @@ 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) { + 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; + } + default: + break; } } @@ -142,7 +197,7 @@ export class ChatPanelProvider } private getIframeHtml(embedUrl: string, allowedOrigin: string): string { - const nonce = randomBytes(16).toString("base64"); + const nonce = getNonce(); return /* html */ ` @@ -205,6 +260,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 +277,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.')); @@ -248,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/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..f8e3b490 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,15 @@ export class EventEmitter { const onDidChangeConfiguration = new EventEmitter(); const onDidChangeWorkspaceFolders = new EventEmitter(); +const onDidChangeActiveColorTheme = new EventEmitter(); export const window = { + activeColorTheme: { kind: ColorThemeKind.Dark }, + onDidChangeActiveColorTheme: onDidChangeActiveColorTheme.event, + __setActiveColorThemeKind: (kind: number) => { + window.activeColorTheme = { kind }; + onDidChangeActiveColorTheme.fire({ kind }); + }, showInformationMessage: vi.fn(), showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), @@ -151,6 +164,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/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts index b5e64925..e2671ed4 100644 --- a/test/unit/uri/uriHandler.test.ts +++ b/test/unit/uri/uriHandler.test.ts @@ -12,11 +12,10 @@ 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"; vi.mock("@/promptUtils", () => ({ maybeAskUrl: vi.fn() })); @@ -77,6 +76,7 @@ function createTestContext() { getSecretsManager: () => secretsManager, getMementoManager: () => mementoManager, getLoginCoordinator: () => loginCoordinator as unknown as LoginCoordinator, + getContextManager: () => new MockContextManager(), getLogger: () => logger, } as unknown as ServiceContainer; @@ -94,11 +94,14 @@ 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() }; + + registerUriHandler({ + serviceContainer: container, + deploymentManager, + commands, + chatPanelProvider, + }); return { commands, @@ -107,6 +110,7 @@ function createTestContext() { secretsManager, logger, showErrorMessage, + chatPanelProvider, handleUri: registeredHandler!, }; } @@ -150,6 +154,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 new file mode 100644 index 00000000..2b7d94d0 --- /dev/null +++ b/test/unit/webviews/chat/chatPanelProvider.test.ts @@ -0,0 +1,214 @@ +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 createHarness(): Harness { + const client = new MockCoderApi(); + client.setCredentials("https://coder.example.com", "test-token"); + + const provider = new ChatPanelProvider(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 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({ type: "coder:chat-ready" }); + + expect(findPostedMessage(postMessage, "coder:set-theme")).toEqual({ + type: "coder:set-theme", + theme: expected, + }); + }); + + it("sends scroll-to-bottom on chat-ready", () => { + const { sendFromWebview, postMessage } = createHarness(); + + sendFromWebview({ type: "coder:chat-ready" }); + + expect(findPostedMessage(postMessage, "coder:scroll-to-bottom")).toEqual({ + type: "coder:scroll-to-bottom", + }); + }); + + 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", + theme: "light", + }); + }); + }); + + describe("auth flow", () => { + it("sends auth token on coder:vscode-ready", () => { + const { sendFromWebview, postMessage } = createHarness(); + + sendFromWebview({ type: "coder:vscode-ready" }); + + expect( + findPostedMessage(postMessage, "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(); + }); + + 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", () => { + 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"); + + 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(); + }); + }); +});