From 3396c9e9c3b1f3ec6e59b35c75598852c93662a7 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 5 Sep 2026 02:18:07 +0800 Subject: [PATCH 1/8] fix(activate): target title-bar commands to their click-origin instance --- packages/types/src/vscode.ts | 8 + .../__tests__/registerCommands.spec.ts | 299 +++++++++++++++--- src/activate/registerCommands.ts | 126 ++++++-- src/core/webview/ClineProvider.ts | 10 + .../webview/__tests__/ClineProvider.spec.ts | 13 + src/package.json | 28 +- 6 files changed, 411 insertions(+), 73 deletions(-) diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index fd4e31116d..6a1a08b821 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -35,6 +35,14 @@ export const commandIds = [ "popoutButtonClicked", "settingsButtonClicked", + // Editor-tab (popped-out) surface variants of the title-bar buttons. The + // shared ids above target the sidebar click origin, so the tab surface + // needs its own ids (see registerCommands.ts getTabProvider). + "plusButtonClickedInTab", + "settingsButtonClickedInTab", + "marketplaceButtonClickedInTab", + "historyButtonClickedInTab", + "openInNewTab", "newTask", diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 67a2b935ec..e3b5b887fa 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -1,5 +1,7 @@ import type { Mock } from "vitest" import * as vscode from "vscode" +import { TelemetryService } from "@roo-code/telemetry" + import { ClineProvider } from "../../core/webview/ClineProvider" import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" @@ -192,44 +194,104 @@ describe("registerCommands handlers", () => { expect(mockContext.subscriptions).toContain(disposable) }) - it("settingsButtonClicked posts both settingsButtonClicked and didBecomeVisible actions", () => { + // The sidebar title-bar handlers target the registered provider (the + // sidebar click origin) directly, not the visible-instance heuristic. + it("settingsButtonClicked posts both settingsButtonClicked and didBecomeVisible actions on the registered provider", () => { handlers["zoo-code.settingsButtonClicked"]() - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledWith({ + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("settings") + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "settingsButtonClicked", }) - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledWith({ + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "didBecomeVisible", }) - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledTimes(2) - }) - - it("settingsButtonClicked is a no-op when no visible provider", () => { - ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) - - handlers["zoo-code.settingsButtonClicked"]() - + expect(mockProvider.postMessageToWebview).toHaveBeenCalledTimes(2) expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) - it("historyButtonClicked posts historyButtonClicked action", () => { + it("historyButtonClicked posts historyButtonClicked action on the registered provider", () => { handlers["zoo-code.historyButtonClicked"]() - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledWith({ + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("history") + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "historyButtonClicked", }) + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) - it("marketplaceButtonClicked posts marketplaceButtonClicked action", () => { + it("marketplaceButtonClicked posts marketplaceButtonClicked action on the registered provider", () => { handlers["zoo-code.marketplaceButtonClicked"]() - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledWith({ + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "marketplaceButtonClicked", }) + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + // The `*InTab` handlers serve the `editor/title` menu: they target the + // instance that owns the tracked tab panel, resolved via + // ClineProvider.getInstanceForView. + const tabHandlerCases: { command: string; actions: string[]; telemetry?: string }[] = [ + { + command: "zoo-code.settingsButtonClickedInTab", + actions: ["settingsButtonClicked", "didBecomeVisible"], + telemetry: "settings", + }, + { command: "zoo-code.historyButtonClickedInTab", actions: ["historyButtonClicked"], telemetry: "history" }, + { command: "zoo-code.marketplaceButtonClickedInTab", actions: ["marketplaceButtonClicked"] }, + ] + it.each(tabHandlerCases)( + "$command targets the tab instance for the tracked tab panel", + ({ command, actions, telemetry }) => { + const mockTabProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + handlers[command]() + + for (const action of actions) { + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action }) + } + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledTimes(actions.length) + if (telemetry) { + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith(telemetry) + } + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }, + ) + + // The `*InTab` handlers must no-op when there is no live tab instance: a + // missing or disposed tab must not crash the handler or fall back to + // another instance. Every handler is awaited, so an async handler that + // slipped past its guard (rejecting on the missing instance) fails the + // test instead of settling as an unhandled rejection. + const inTabNoOpCommands = [ + "zoo-code.plusButtonClickedInTab", + "zoo-code.settingsButtonClickedInTab", + "zoo-code.historyButtonClickedInTab", + "zoo-code.marketplaceButtonClickedInTab", + ] + it.each(inTabNoOpCommands)("$command is a no-op when no tab panel is tracked", async (command) => { + await handlers[command]() + + expect(ClineProvider.getInstanceForView as Mock).not.toHaveBeenCalled() + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it.each(inTabNoOpCommands)("$command is a no-op when the tab instance is disposed", async (command) => { + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + await handlers[command]() + + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) it("acceptInput posts acceptInput message", () => { @@ -302,44 +364,138 @@ describe("registerCommands handlers", () => { }) }) - it("focusInput does not post when no sidebar panel is active", async () => { + it("focusInput does not post when no sidebar panel is tracked", async () => { await handlers["zoo-code.focusInput"]() expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) - // Representative coverage for the .catch arm on all five void-prefixed - // postMessageToWebview sites in registerCommands.ts (settingsButtonClicked - // posts twice, plus historyButtonClicked, marketplaceButtonClicked, and - // acceptInput). Each handler is synchronous, so the .catch arm runs on a - // microtask; setImmediate ensures all microtasks are flushed before we assert. The + it("focusInput does not post when a tab panel is tracked alongside the sidebar", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + setPanel({} as vscode.WebviewPanel, "tab") + + await handlers["zoo-code.focusInput"]() + + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("setPanel keeps independent refs: clearing only the tab ref re-enables the sidebar post", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + setPanel({} as vscode.WebviewPanel, "tab") + + // The tab ref does not wipe the sidebar ref... + await handlers["zoo-code.focusInput"]() + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + + // ...and clearing only the tab ref re-enables the sidebar post. + setPanel(undefined, "tab") + await handlers["zoo-code.focusInput"]() + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) + }) + + // Coverage for the .catch arm on the sidebar title-bar post sites + // (settingsButtonClicked posts twice, plus historyButtonClicked and + // marketplaceButtonClicked) and acceptInput (the visible-provider path). + // Each handler is synchronous, so the .catch arm runs on a microtask; + // setImmediate ensures all microtasks are flushed before we assert. The // log messages carry a `[]` prefix so multi-failure logs // remain unambiguous; the prefix is per-handler, not per-call (both of - // settingsButtonClicked's posts share the same prefix). + // settingsButtonClicked's posts share the same prefix). Each post rejects + // with its own error and call N is pinned to post N, so a mutant that + // alters one catch's message cannot hide behind the other post's + // identical log. it.each([ - { command: "zoo-code.settingsButtonClicked", prefix: "settingsButtonClicked", expectedCalls: 2 }, - { command: "zoo-code.historyButtonClicked", prefix: "historyButtonClicked", expectedCalls: 1 }, - { command: "zoo-code.marketplaceButtonClicked", prefix: "marketplaceButtonClicked", expectedCalls: 1 }, - { command: "zoo-code.acceptInput", prefix: "acceptInput", expectedCalls: 1 }, + { + command: "zoo-code.settingsButtonClicked", + prefix: "settingsButtonClicked", + errorLabels: ["first post", "second post"], + target: "sidebar" as const, + }, + { + command: "zoo-code.historyButtonClicked", + prefix: "historyButtonClicked", + errorLabels: ["post"], + target: "sidebar" as const, + }, + { + command: "zoo-code.marketplaceButtonClicked", + prefix: "marketplaceButtonClicked", + errorLabels: ["post"], + target: "sidebar" as const, + }, + { command: "zoo-code.acceptInput", prefix: "acceptInput", errorLabels: ["post"], target: "visible" as const }, ])( "$command logs to outputChannel when postMessageToWebview rejects", - async ({ command, prefix, expectedCalls }) => { - const boom = new Error("boom") - mockVisibleProvider.postMessageToWebview.mockReset() - mockVisibleProvider.postMessageToWebview.mockRejectedValue(boom) + async ({ command, prefix, errorLabels, target }) => { + const post = + target === "sidebar" ? mockProvider.postMessageToWebview : mockVisibleProvider.postMessageToWebview + post.mockReset() + const booms = errorLabels.map((label) => new Error(label)) + booms.forEach((boom) => post.mockRejectedValueOnce(boom)) handlers[command]() - // Flush microtasks so the chained .catch arm runs. + // Flush microtasks so the chained .catch arms run. await new Promise((resolve) => setImmediate(resolve)) - expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(expectedCalls) - expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( - `[${prefix}] postMessageToWebview failed: ${boom}`, - ) + expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(booms.length) + booms.forEach((boom, index) => { + expect(mockOutputChannel.appendLine).toHaveBeenNthCalledWith( + index + 1, + `[${prefix}] postMessageToWebview failed: ${boom}`, + ) + }) }, ) + // The two posts reject with distinct errors and the nth-call assertions + // pin each catch's message, so neither template literal can survive + // behind the other post's identical log. + it("settingsButtonClickedInTab logs to outputChannel when postMessageToWebview rejects", async () => { + const booms = [new Error("first post"), new Error("second post")] + const mockTabProvider = { + postMessageToWebview: vi.fn().mockRejectedValueOnce(booms[0]).mockRejectedValueOnce(booms[1]), + } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + handlers["zoo-code.settingsButtonClickedInTab"]() + + // Flush microtasks so the chained .catch arms run. + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(2) + expect(mockOutputChannel.appendLine).toHaveBeenNthCalledWith( + 1, + `[settingsButtonClickedInTab] postMessageToWebview failed: ${booms[0]}`, + ) + expect(mockOutputChannel.appendLine).toHaveBeenNthCalledWith( + 2, + `[settingsButtonClickedInTab] postMessageToWebview failed: ${booms[1]}`, + ) + }) + + // The history and marketplace InTab catch sites share the identical + // single-post pattern (their sidebar equivalents are covered by the + // it.each above); pin their exact messages too. + it.each([ + { command: "zoo-code.historyButtonClickedInTab", prefix: "historyButtonClickedInTab" }, + { command: "zoo-code.marketplaceButtonClickedInTab", prefix: "marketplaceButtonClickedInTab" }, + ])("$command logs to outputChannel when the tab postMessageToWebview rejects", async ({ command, prefix }) => { + const boom = new Error("post") + const mockTabProvider = { postMessageToWebview: vi.fn().mockRejectedValue(boom) } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + handlers[command]() + + // Flush microtasks so the chained .catch arm runs. + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(1) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(`[${prefix}] postMessageToWebview failed: ${boom}`) + }) + it("toggleAutoApprove logs to outputChannel when postMessageToWebview rejects", async () => { // toggleAutoApprove is `async` and awaits postMessageToWebview inside a // try/catch (rather than relying on a `.catch` microtask like the @@ -357,22 +513,40 @@ describe("registerCommands handlers", () => { ) }) - it("plusButtonClicked calls evictCurrentTask on the visible provider", async () => { + it("plusButtonClicked calls evictCurrentTask on the registered sidebar provider", async () => { const evictCurrentTask = vi.fn().mockResolvedValue(undefined) const refreshWorkspace = vi.fn().mockResolvedValue(undefined) - ;(mockVisibleProvider as any).evictCurrentTask = evictCurrentTask - ;(mockVisibleProvider as any).refreshWorkspace = refreshWorkspace + ;(mockProvider as any).evictCurrentTask = evictCurrentTask + ;(mockProvider as any).refreshWorkspace = refreshWorkspace await handlers["zoo-code.plusButtonClicked"]() + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") expect(evictCurrentTask).toHaveBeenCalledTimes(1) + expect(refreshWorkspace).toHaveBeenCalledTimes(1) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "chatButtonClicked" }) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) }) - it("plusButtonClicked is a no-op when no visible provider", async () => { - ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) + it("plusButtonClickedInTab evicts and posts on the tab instance for the tracked tab panel", async () => { + const mockTabProvider = { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), + } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) - // Should not throw even with no visible provider - await handlers["zoo-code.plusButtonClicked"]() + await handlers["zoo-code.plusButtonClickedInTab"]() + + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") + expect(mockTabProvider.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(mockTabProvider.refreshWorkspace).toHaveBeenCalledTimes(1) + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "chatButtonClicked", + }) + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) }) }) @@ -414,6 +588,9 @@ describe("openClineInNewTab", () => { it("creates a webview panel with title 'Zoo Code'", async () => { await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + // No tab was tracked, so the reuse path (and its instance lookup) + // must not run. + expect(ClineProvider.getInstanceForView as Mock).not.toHaveBeenCalled() expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( "zoo-code.TabPanelProvider", "Zoo Code", @@ -424,4 +601,42 @@ describe("openClineInNewTab", () => { }), ) }) + + it("reveals the existing tab instead of creating a second panel", async () => { + const mockExistingProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const mockPanel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + reveal: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.WebviewPanel + setPanel(mockPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockExistingProvider) + + const result = await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + expect(result).toBe(mockExistingProvider) + expect(mockPanel.reveal).toHaveBeenCalledTimes(1) + expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled() + expect(mockExistingProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "didBecomeVisible", + }) + }) + + it("creates a new tab panel when the tracked tab's provider has been disposed", async () => { + const mockPanel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + reveal: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.WebviewPanel + setPanel(mockPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + expect(mockPanel.reveal).not.toHaveBeenCalled() + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 692aabfd68..500e7752bc 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -41,7 +41,12 @@ export function getPanel(): vscode.WebviewPanel | vscode.WebviewView | undefined } /** - * Set panel references + * Set panel references. + * + * The two refs are independent: each surface keeps its own ref for its whole + * lifetime, so resolving the sidebar view never wipes a live tab panel (and + * vice versa). Callers pass `undefined` only when the surface itself is + * disposed (see the `onDidDispose` wiring in `openClineInNewTab`). */ export function setPanel( newPanel: vscode.WebviewPanel | vscode.WebviewView | undefined, @@ -49,13 +54,22 @@ export function setPanel( ): void { if (type === "sidebar") { sidebarPanel = newPanel as vscode.WebviewView - tabPanel = undefined } else { tabPanel = newPanel as vscode.WebviewPanel - sidebarPanel = undefined } } +/** + * The instance that owns the tracked tab panel, if it is still alive. + * + * Title-bar commands on the editor-tab surface use this instead of the + * visible-instance heuristic, so a click on the tab's title bar always + * targets that tab even when the sidebar is visible side-by-side. + */ +function getTabProvider(): ClineProvider | undefined { + return tabPanel ? ClineProvider.getInstanceForView(tabPanel) : undefined +} + export type RegisterCommandOptions = { context: vscode.ExtensionContext outputChannel: vscode.OutputChannel @@ -91,21 +105,35 @@ const getCommandsMap = ({ provider, }: RegisterCommandOptions): Record, CommandCallback> => ({ activationCompleted: () => {}, + // The `view/title` menu is scoped to the sidebar view, so the click + // origin of these handlers is the sidebar provider wired in at + // activation (`provider`). Target it directly instead of the + // visible-instance heuristic, which would follow the user's focus to a + // tab instance when both surfaces are open side-by-side. The `*InTab` + // variants serve the `editor/title` menu and target the tab instance + // through `getTabProvider()` instead. plusButtonClicked: async () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("plus") - if (!visibleProvider) { + await provider.evictCurrentTask() + await provider.refreshWorkspace() + await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + // Send focusInput action immediately after chatButtonClicked + // This ensures the focus happens after the view has switched + await provider.postMessageToWebview({ type: "action", action: "focusInput" }) + }, + plusButtonClickedInTab: async () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("plus") - await visibleProvider.evictCurrentTask() - await visibleProvider.refreshWorkspace() - await visibleProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) - // Send focusInput action immediately after chatButtonClicked - // This ensures the focus happens after the view has switched - await visibleProvider.postMessageToWebview({ type: "action", action: "focusInput" }) + await tabProvider.evictCurrentTask() + await tabProvider.refreshWorkspace() + await tabProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + await tabProvider.postMessageToWebview({ type: "action", action: "focusInput" }) }, popoutButtonClicked: () => { TelemetryService.instance.captureTitleButtonClicked("popout") @@ -114,44 +142,74 @@ const getCommandsMap = ({ }, openInNewTab: () => openClineInNewTab({ context, outputChannel }), settingsButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("settings") - if (!visibleProvider) { + void provider + .postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) + .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) + // Also explicitly post the visibility message to trigger scroll reliably + void provider + .postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) + }, + settingsButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("settings") - void visibleProvider + void tabProvider .postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) - // Also explicitly post the visibility message to trigger scroll reliably - void visibleProvider + .catch((error) => + outputChannel.appendLine(`[settingsButtonClickedInTab] postMessageToWebview failed: ${error}`), + ) + void tabProvider .postMessageToWebview({ type: "action", action: "didBecomeVisible" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) + .catch((error) => + outputChannel.appendLine(`[settingsButtonClickedInTab] postMessageToWebview failed: ${error}`), + ) }, historyButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("history") - if (!visibleProvider) { + void provider + .postMessageToWebview({ type: "action", action: "historyButtonClicked" }) + .catch((error) => outputChannel.appendLine(`[historyButtonClicked] postMessageToWebview failed: ${error}`)) + }, + historyButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("history") - void visibleProvider + void tabProvider .postMessageToWebview({ type: "action", action: "historyButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[historyButtonClicked] postMessageToWebview failed: ${error}`)) + .catch((error) => + outputChannel.appendLine(`[historyButtonClickedInTab] postMessageToWebview failed: ${error}`), + ) }, marketplaceButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) - if (!visibleProvider) return - void visibleProvider + void provider .postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) .catch((error) => outputChannel.appendLine(`[marketplaceButtonClicked] postMessageToWebview failed: ${error}`), ) }, + marketplaceButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { + return + } + void tabProvider + .postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) + .catch((error) => + outputChannel.appendLine(`[marketplaceButtonClickedInTab] postMessageToWebview failed: ${error}`), + ) + }, newTask: handleNewTask, setCustomStoragePath: async () => { const { promptForCustomStoragePath } = await import("../utils/storage") @@ -177,8 +235,11 @@ const getCommandsMap = ({ try { await focusPanel(tabPanel, sidebarPanel) - // Send focus input message only for sidebar panels - if (sidebarPanel && getPanel() === sidebarPanel) { + // Send focus input message only when the sidebar panel was + // focused: the tab takes selection priority in focusPanel, so + // the sidebar receives the message only when no tab panel is + // tracked. + if (sidebarPanel && !tabPanel) { await provider.postMessageToWebview({ type: "action", action: "focusInput" }) } } catch (error) { @@ -222,6 +283,17 @@ const getCommandsMap = ({ }) export const openClineInNewTab = async ({ context, outputChannel }: Omit) => { + // Reuse the tracked tab instead of opening a second one: a repeated + // "Open in editor" click reveals the existing tab's panel. + if (tabPanel) { + const existingProvider = ClineProvider.getInstanceForView(tabPanel) + if (existingProvider) { + await tabPanel.reveal() + await existingProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + return existingProvider + } + } + // (This example uses webviewProvider activation event which is necessary to // deserialize cached webview, but since we use retainContextWhenHidden, we // don't need to use that event). diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 87a899344c..29c32ae9cf 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -903,6 +903,16 @@ export class ClineProvider return Array.from(this.activeInstances) } + /** + * Returns the live instance whose current view is the given view or panel, + * if any. Title-bar commands on a specific surface use this to target the + * instance that owns that surface rather than the visible-instance + * heuristic (which picks whichever surface the user last focused). + */ + public static getInstanceForView(view: vscode.WebviewView | vscode.WebviewPanel): ClineProvider | undefined { + return Array.from(this.activeInstances).find((instance) => instance.view === view) + } + public static async getInstance(): Promise { let visibleProvider = ClineProvider.getVisibleInstance() diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1a6a82a5b0..3f1bf0875e 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -567,6 +567,19 @@ describe("ClineProvider", () => { expect(ClineProvider.getVisibleInstance()).toBe(provider) }) + describe("getInstanceForView", () => { + it("returns the instance that owns the given view", () => { + // @ts-ignore - accessing private property for testing + provider.view = mockWebviewView + + expect(ClineProvider.getInstanceForView(mockWebviewView)).toBe(provider) + }) + + it("returns undefined when no live instance owns the view", () => { + expect(ClineProvider.getInstanceForView({} as vscode.WebviewView)).toBeUndefined() + }) + }) + test("loads full model details when preparing an LM Studio task", async () => { await provider.performPreparationTasks({ apiConfiguration: { diff --git a/src/package.json b/src/package.json index 4e9bfcfcf7..6753513658 100644 --- a/src/package.json +++ b/src/package.json @@ -95,6 +95,26 @@ "title": "%command.settings.title%", "icon": "$(settings-gear)" }, + { + "command": "zoo-code.plusButtonClickedInTab", + "title": "%command.newTask.title%", + "icon": "$(edit)" + }, + { + "command": "zoo-code.settingsButtonClickedInTab", + "title": "%command.settings.title%", + "icon": "$(settings-gear)" + }, + { + "command": "zoo-code.marketplaceButtonClickedInTab", + "title": "%command.marketplace.title%", + "icon": "$(extensions)" + }, + { + "command": "zoo-code.historyButtonClickedInTab", + "title": "%command.history.title%", + "icon": "$(history)" + }, { "command": "zoo-code.openInNewTab", "title": "%command.openInNewTab.title%", @@ -241,22 +261,22 @@ ], "editor/title": [ { - "command": "zoo-code.plusButtonClicked", + "command": "zoo-code.plusButtonClickedInTab", "group": "navigation@1", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, { - "command": "zoo-code.settingsButtonClicked", + "command": "zoo-code.settingsButtonClickedInTab", "group": "navigation@2", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, { - "command": "zoo-code.marketplaceButtonClicked", + "command": "zoo-code.marketplaceButtonClickedInTab", "group": "navigation@3", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, { - "command": "zoo-code.historyButtonClicked", + "command": "zoo-code.historyButtonClickedInTab", "group": "overflow@1", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, From a14326782a21a8d7d26bd9f2e5e894d875009a07 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 7 Sep 2026 14:55:00 +0800 Subject: [PATCH 2/8] ci: re-trigger PR review-state labeler reconciliation (no-op commit) From 723d871c7a0a0509bce73c40bf31715d6cd200d1 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 7 Sep 2026 15:46:00 +0800 Subject: [PATCH 3/8] fix(activate): serialize overlapping openClineInNewTab calls and harden view-identity tests Track the in-flight tab panel creation with a module-level promise so concurrent openClineInNewTab calls reuse one panel and provider (adds a Promise.all regression test). ClineProvider.spec sets the private view via the public resolveWebviewView() instead of a ts-ignore assignment. registerCommands.spec types evictCurrentTask/refreshWorkspace on the fixture and drops the as any attachment. eslint-suppressions: prune the registerCommands.spec.ts entry (two as any suppressions removed). --- .../__tests__/registerCommands.spec.ts | 30 ++- src/activate/registerCommands.ts | 171 ++++++++++-------- .../webview/__tests__/ClineProvider.spec.ts | 5 +- src/eslint-suppressions.json | 5 - 4 files changed, 123 insertions(+), 88 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index e3b5b887fa..1672c74d21 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -136,7 +136,11 @@ describe("registerCommands handlers", () => { let mockOutputChannel: vscode.OutputChannel let mockContext: vscode.ExtensionContext let mockVisibleProvider: { postMessageToWebview: Mock } - let mockProvider: { postMessageToWebview: Mock } + let mockProvider: { + postMessageToWebview: Mock + evictCurrentTask: Mock + refreshWorkspace: Mock + } let handlers: Record unknown> beforeEach(() => { @@ -164,6 +168,8 @@ describe("registerCommands handlers", () => { mockProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), } ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(mockVisibleProvider) ;(vscode.commands.registerCommand as Mock).mockImplementation( @@ -514,16 +520,11 @@ describe("registerCommands handlers", () => { }) it("plusButtonClicked calls evictCurrentTask on the registered sidebar provider", async () => { - const evictCurrentTask = vi.fn().mockResolvedValue(undefined) - const refreshWorkspace = vi.fn().mockResolvedValue(undefined) - ;(mockProvider as any).evictCurrentTask = evictCurrentTask - ;(mockProvider as any).refreshWorkspace = refreshWorkspace - await handlers["zoo-code.plusButtonClicked"]() expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") - expect(evictCurrentTask).toHaveBeenCalledTimes(1) - expect(refreshWorkspace).toHaveBeenCalledTimes(1) + expect(mockProvider.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(mockProvider.refreshWorkspace).toHaveBeenCalledTimes(1) expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "chatButtonClicked" }) expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) }) @@ -639,4 +640,17 @@ describe("openClineInNewTab", () => { expect(mockPanel.reveal).not.toHaveBeenCalled() expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) }) + + it("serializes concurrent opens so overlapping calls create one panel and share one provider", async () => { + const [first, second] = await Promise.all([ + openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }), + openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }), + ]) + + // Overlapping "Open in editor" calls must share the in-flight + // creation: exactly one tab panel is created and both callers + // receive the same provider. + expect(first).toBe(second) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 500e7752bc..5fd5171a83 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -32,6 +32,12 @@ export function getVisibleProviderOrLog(outputChannel: vscode.OutputChannel): Cl let sidebarPanel: vscode.WebviewView | undefined = undefined let tabPanel: vscode.WebviewPanel | undefined = undefined +// In-flight "open in editor" creation shared by overlapping calls: a +// double-click starts before the first call tracks its new panel, so +// concurrent callers must share one creation instead of racing to create +// two tab panels. +let pendingTabPanelCreation: Promise | undefined + /** * Get the currently active panel * @returns WebviewPanelꈖWebviewView @@ -283,88 +289,109 @@ const getCommandsMap = ({ }) export const openClineInNewTab = async ({ context, outputChannel }: Omit) => { - // Reuse the tracked tab instead of opening a second one: a repeated - // "Open in editor" click reveals the existing tab's panel. - if (tabPanel) { - const existingProvider = ClineProvider.getInstanceForView(tabPanel) - if (existingProvider) { - await tabPanel.reveal() - await existingProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) - return existingProvider - } + // Serialize overlapping "Open in editor" calls: a double-click starts + // before the first call tracks its new panel, so without a shared + // in-flight creation both calls would race to create two tab panels. + // Concurrent callers await the same promise: exactly one panel is + // created and every caller receives the same provider. + if (pendingTabPanelCreation) { + return pendingTabPanelCreation } - // (This example uses webviewProvider activation event which is necessary to - // deserialize cached webview, but since we use retainContextWhenHidden, we - // don't need to use that event). - // https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts - const contextProxy = await ContextProxy.getInstance(context) - const codeIndexManager = CodeIndexManager.getInstance(context) + const creation = (async () => { + // Reuse the tracked tab instead of opening a second one: a repeated + // "Open in editor" click reveals the existing tab's panel. + if (tabPanel) { + const existingProvider = ClineProvider.getInstanceForView(tabPanel) + if (existingProvider) { + await tabPanel.reveal() + await existingProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + return existingProvider + } + } - // Get the existing MDM service instance to ensure consistent policy enforcement - let mdmService: MdmService | undefined - try { - mdmService = MdmService.getInstance() - } catch (error) { - // MDM service not initialized, which is fine - extension can work without it - mdmService = undefined - } + // (This example uses webviewProvider activation event which is necessary to + // deserialize cached webview, but since we use retainContextWhenHidden, we + // don't need to use that event). + // https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts + const contextProxy = await ContextProxy.getInstance(context) + const codeIndexManager = CodeIndexManager.getInstance(context) - const tabProvider = new ClineProvider(context, outputChannel, "editor", contextProxy, mdmService) - const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) + // Get the existing MDM service instance to ensure consistent policy enforcement + let mdmService: MdmService | undefined + try { + mdmService = MdmService.getInstance() + } catch (error) { + // MDM service not initialized, which is fine - extension can work without it + mdmService = undefined + } - // Check if there are any visible text editors, otherwise open a new group - // to the right. - const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 + const tabProvider = new ClineProvider(context, outputChannel, "editor", contextProxy, mdmService) + const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) - if (!hasVisibleEditors) { - await vscode.commands.executeCommand("workbench.action.newGroupRight") - } + // Check if there are any visible text editors, otherwise open a new group + // to the right. + const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 - const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two + if (!hasVisibleEditors) { + await vscode.commands.executeCommand("workbench.action.newGroupRight") + } - const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [context.extensionUri], - }) + const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two - // Save as tab type panel. - setPanel(newPanel, "tab") + const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [context.extensionUri], + }) - // TODO: Use better svg icon with light and dark variants (see - // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). - newPanel.iconPath = { - light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_light.png"), - dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), - } + // Save as tab type panel. + setPanel(newPanel, "tab") + + // TODO: Use better svg icon with light and dark variants (see + // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). + newPanel.iconPath = { + light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_light.png"), + dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), + } - await tabProvider.resolveWebviewView(newPanel) + await tabProvider.resolveWebviewView(newPanel) - // Add listener for visibility changes to notify webview - newPanel.onDidChangeViewState( - (e) => { - const panel = e.webviewPanel - if (panel.visible) { - panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx - } - }, - null, // First null is for `thisArgs` - context.subscriptions, // Register listener for disposal - ) - - // Handle panel closing events. - newPanel.onDidDispose( - () => { - setPanel(undefined, "tab") - }, - null, - context.subscriptions, // Also register dispose listener - ) - - // Lock the editor group so clicking on files doesn't open them over the panel. - await delay(100) - await vscode.commands.executeCommand("workbench.action.lockEditorGroup") - - return tabProvider + // Add listener for visibility changes to notify webview + newPanel.onDidChangeViewState( + (e) => { + const panel = e.webviewPanel + if (panel.visible) { + panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx + } + }, + null, // First null is for `thisArgs` + context.subscriptions, // Register listener for disposal + ) + + // Handle panel closing events. + newPanel.onDidDispose( + () => { + setPanel(undefined, "tab") + }, + null, + context.subscriptions, // Also register dispose listener + ) + + // Lock the editor group so clicking on files doesn't open them over the panel. + await delay(100) + await vscode.commands.executeCommand("workbench.action.lockEditorGroup") + + return tabProvider + })() + + pendingTabPanelCreation = creation + + try { + return await creation + } finally { + // Clear once settled (success or failure) so the next call starts + // fresh: the reuse path above then takes over for the tracked panel. + pendingTabPanelCreation = undefined + } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 3f1bf0875e..84b80cf945 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -568,9 +568,8 @@ describe("ClineProvider", () => { }) describe("getInstanceForView", () => { - it("returns the instance that owns the given view", () => { - // @ts-ignore - accessing private property for testing - provider.view = mockWebviewView + it("returns the instance that owns the given view", async () => { + await provider.resolveWebviewView(mockWebviewView) expect(ClineProvider.getInstanceForView(mockWebviewView)).toBe(provider) }) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 381cf0c1e0..544886c2d7 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -64,11 +64,6 @@ "count": 14 } }, - "activate/__tests__/registerCommands.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "activate/registerCodeActions.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 From c3027c8f1408bb0293c3ba36dc27059c7c5251d5 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 7 Sep 2026 16:13:46 +0800 Subject: [PATCH 4/8] test(activate): pin openClineInNewTab creation branches and strengthen the concurrency assertion --- .../__tests__/registerCommands.spec.ts | 122 +++++++++++++++++- 1 file changed, 118 insertions(+), 4 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 1672c74d21..05e71824bf 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -3,8 +3,9 @@ import * as vscode from "vscode" import { TelemetryService } from "@roo-code/telemetry" import { ClineProvider } from "../../core/webview/ClineProvider" +import { MdmService } from "../../services/mdm/MdmService" -import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" +import { getPanel, getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" vi.mock("execa", () => ({ execa: vi.fn(), @@ -641,6 +642,113 @@ describe("openClineInNewTab", () => { expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) }) + it("falls back to an undefined MdmService when MdmService.getInstance throws", async () => { + ;(MdmService.getInstance as Mock).mockImplementation(() => { + throw new Error("MDM service not initialized") + }) + + const provider = await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + // The creation must survive the MDM lookup failure: the provider is + // constructed with an undefined MDM service and the tab panel is + // still created. + const ctor = ClineProvider as unknown as Mock + expect(ctor.mock.instances[0]).toBeDefined() + expect(ctor).toHaveBeenCalledWith(mockContext, mockOutputChannel, "editor", undefined, undefined) + expect(provider).toBe(ctor.mock.instances[0]) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("opens a new group to the right and targets ViewColumn.Two when no editors are visible", async () => { + ;(vscode.window as unknown as { visibleTextEditors: vscode.TextEditor[] }).visibleTextEditors = [] + + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.newGroupRight") + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.lockEditorGroup") + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + vscode.ViewColumn.Two, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [mockContext.extensionUri], + }, + ) + + // The panel icon points at the extension's asset files. + const panel = (vscode.window.createWebviewPanel as Mock).mock.results[0].value as { + iconPath?: { light: { path: string }; dark: { path: string } } + } + expect(panel.iconPath).toEqual({ + light: { path: "assets/icons/panel_light.png" }, + dark: { path: "assets/icons/panel_dark.png" }, + }) + }) + + it("treats editors without a viewColumn as column 0 when computing the target column", async () => { + // openClineInNewTab only reads viewColumn from each editor, so the + // fixture keeps that single field. + const editorWithoutColumn = { viewColumn: undefined } as unknown as vscode.TextEditor + ;(vscode.window as unknown as { visibleTextEditors: vscode.TextEditor[] }).visibleTextEditors = [ + editorWithoutColumn, + ] + + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + // lastCol falls back to 0, so the panel lands on column 1 instead of + // opening a new editor group. + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.newGroupRight") + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + 1, + expect.objectContaining({ enableScripts: true }), + ) + }) + + it("constructs the tab provider with the 'editor' context and the live MdmService instance", async () => { + const mockMdm = { name: "mock-mdm" } + // MdmService has a private constructor, so pin a sentinel stand-in. + ;(MdmService.getInstance as Mock).mockReturnValue(mockMdm as unknown as MdmService) + + const provider = await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + const ctor = ClineProvider as unknown as Mock + expect(ctor).toHaveBeenCalledTimes(1) + expect(ctor).toHaveBeenCalledWith(mockContext, mockOutputChannel, "editor", undefined, mockMdm) + expect(provider).toBe(ctor.mock.instances[0]) + }) + + it("posts didBecomeVisible only for visible state changes and clears the tracked tab on dispose", async () => { + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + expect(getPanel()).toBeDefined() + + const panel = (vscode.window.createWebviewPanel as Mock).mock.results[0].value as { + onDidChangeViewState: Mock + onDidDispose: Mock + } + const stateHandler = panel.onDidChangeViewState.mock.calls[0][0] as (event: { + webviewPanel: { visible: boolean; webview: { postMessage: (message: unknown) => void } } + }) => void + const visibleEvent = { webviewPanel: { visible: true, webview: { postMessage: vi.fn() } } } + stateHandler(visibleEvent) + expect(visibleEvent.webviewPanel.webview.postMessage).toHaveBeenCalledWith({ + type: "action", + action: "didBecomeVisible", + }) + + const hiddenEvent = { webviewPanel: { visible: false, webview: { postMessage: vi.fn() } } } + stateHandler(hiddenEvent) + expect(hiddenEvent.webviewPanel.webview.postMessage).not.toHaveBeenCalled() + + const disposeHandler = panel.onDidDispose.mock.calls[0][0] as () => void + disposeHandler() + expect(getPanel()).toBeUndefined() + }) + it("serializes concurrent opens so overlapping calls create one panel and share one provider", async () => { const [first, second] = await Promise.all([ openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }), @@ -648,9 +756,15 @@ describe("openClineInNewTab", () => { ]) // Overlapping "Open in editor" calls must share the in-flight - // creation: exactly one tab panel is created and both callers - // receive the same provider. - expect(first).toBe(second) + // creation: exactly one tab panel is created and both callers receive + // the same constructed provider. Pinning both results against the + // mocked constructor (not just against each other) keeps the test + // failing if the shared result is undefined. + const ctor = ClineProvider as unknown as Mock + const constructed = ctor.mock.instances[0] + expect(constructed).toBeDefined() + expect(first).toBe(constructed) + expect(second).toBe(constructed) expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) }) }) From 00eb15fd3c0d88725ee3887d4b1086f9d550210a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 00:11:44 +0800 Subject: [PATCH 5/8] fix(activate): harden tab creation serialization and centralize title-bar posts - openClineInNewTab: extract the unserialized creation body into createTabPanelUnlocked and guard the in-flight slot clear so a settled creation cannot clobber a replacement already stored in the slot. - onDidDispose: clear the tracked tab ref only when the disposing panel is still the tracked one, so a late disposal of a replaced panel cannot clobber the replacement's ref. - MDM lookup failure: log the fallback to the output channel instead of swallowing it silently. - Route the six title-bar button handlers through a shared postActions helper that posts each action in order and logs failures with the handler-specific prefix. - package.json: add the four InTab commands to the command palette, scoped to the active tab panel. - Tests: handler-level regression for openInNewTab + popoutButtonClicked started before the first creation resolves; fresh-creation test for a settled in-flight promise; stale-panel disposal regression; retained panel assertion for disposed tab instances; rightmost-editor column placement assertion; MDM fallback output assertion; %s placeholders for primitive it.each titles. - Stryker directives for the two equivalent setPanel type-literal mutants (setPanel branches only on type === sidebar). --- .../__tests__/registerCommands.spec.ts | 152 ++++++++++- src/activate/registerCommands.ts | 252 ++++++++++-------- src/package.json | 18 ++ 3 files changed, 301 insertions(+), 121 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 05e71824bf..d57c6f8e9f 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -2,6 +2,7 @@ import type { Mock } from "vitest" import * as vscode from "vscode" import { TelemetryService } from "@roo-code/telemetry" +import { ContextProxy } from "../../core/config/ContextProxy" import { ClineProvider } from "../../core/webview/ClineProvider" import { MdmService } from "../../services/mdm/MdmService" @@ -283,7 +284,7 @@ describe("registerCommands handlers", () => { "zoo-code.historyButtonClickedInTab", "zoo-code.marketplaceButtonClickedInTab", ] - it.each(inTabNoOpCommands)("$command is a no-op when no tab panel is tracked", async (command) => { + it.each(inTabNoOpCommands)("%s is a no-op when no tab panel is tracked", async (command) => { await handlers[command]() expect(ClineProvider.getInstanceForView as Mock).not.toHaveBeenCalled() @@ -291,12 +292,14 @@ describe("registerCommands handlers", () => { expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) - it.each(inTabNoOpCommands)("$command is a no-op when the tab instance is disposed", async (command) => { - setPanel({} as vscode.WebviewPanel, "tab") + it.each(inTabNoOpCommands)("%s is a no-op when the tab instance is disposed", async (command) => { + const disposedPanel = {} as vscode.WebviewPanel + setPanel(disposedPanel, "tab") ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) await handlers[command]() + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(disposedPanel) expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) @@ -657,6 +660,11 @@ describe("openClineInNewTab", () => { expect(ctor).toHaveBeenCalledWith(mockContext, mockOutputChannel, "editor", undefined, undefined) expect(provider).toBe(ctor.mock.instances[0]) expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + + // The fallback is observable in the output channel. + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[openClineInNewTab] MDM service unavailable, continuing without it: Error: MDM service not initialized", + ) }) it("opens a new group to the right and targets ViewColumn.Two when no editors are visible", async () => { @@ -708,6 +716,27 @@ describe("openClineInNewTab", () => { ) }) + it("places the tab panel one column right of the rightmost visible editor", async () => { + // openClineInNewTab only reads viewColumn from each editor, so the + // fixtures keep that single field. + ;(vscode.window as unknown as { visibleTextEditors: vscode.TextEditor[] }).visibleTextEditors = [ + { viewColumn: 1 } as unknown as vscode.TextEditor, + { viewColumn: 3 } as unknown as vscode.TextEditor, + ] + + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + // lastCol is 3, so the panel lands on column 4 without opening a new + // editor group. + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.newGroupRight") + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + 4, + expect.objectContaining({ enableScripts: true }), + ) + }) + it("constructs the tab provider with the 'editor' context and the live MdmService instance", async () => { const mockMdm = { name: "mock-mdm" } // MdmService has a private constructor, so pin a sentinel stand-in. @@ -767,4 +796,121 @@ describe("openClineInNewTab", () => { expect(second).toBe(constructed) expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) }) + + it("shares one in-flight creation when openInNewTab and popoutButtonClicked start before it resolves", async () => { + // Defer the first creation at ContextProxy.getInstance so both command + // handlers can start while the creation is still in flight. + let resolveContextProxy!: () => void + ;(ContextProxy.getInstance as Mock).mockReturnValue( + new Promise((resolve) => { + resolveContextProxy = resolve + }), + ) + + const commandHandlers: Record unknown> = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + commandHandlers[id] = cb + return { dispose: vi.fn() } + }, + ) + const sidebarProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: sidebarProvider as unknown as ClineProvider, + }) + + const started = [commandHandlers["zoo-code.openInNewTab"](), commandHandlers["zoo-code.popoutButtonClicked"]()] + + // While the shared creation is suspended at ContextProxy.getInstance, + // neither caller has created a panel yet. + expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled() + + resolveContextProxy() + const [first, second] = await Promise.all(started) + + // Both command entry points await the shared in-flight creation: + // exactly one tab panel is created and both results are the same + // constructed provider. + const ctor = ClineProvider as unknown as Mock + const constructed = ctor.mock.instances[0] + expect(constructed).toBeDefined() + expect(first).toBe(constructed) + expect(second).toBe(constructed) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("creates a fresh panel for a new call once the previous creation settled and its provider disposed", async () => { + // The first open settles and tracks its panel. + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + + // The tracked provider is disposed, so the next open cannot reuse the + // existing tab: the settled (and cleared) in-flight promise must not + // be returned, and a fresh panel is created. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + const secondProvider = await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + const ctor = ClineProvider as unknown as Mock + const second = ctor.mock.instances[1] + expect(second).toBeDefined() + expect(secondProvider).toBe(second) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + }) + + it("keeps the replacement panel tracked when a stale panel's disposal fires late", async () => { + // Capture each created panel so the first panel's (stale) dispose + // handler can fire after the replacement is already tracked. + const createdPanels: { onDidDispose: Mock }[] = [] + ;(vscode.window.createWebviewPanel as Mock).mockImplementation(() => { + const panel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } + createdPanels.push(panel) + return panel + }) + + // First open creates and tracks panel A. + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + expect(getPanel()).toBe(createdPanels[0]) + + // Panel A's provider is disposed before the second open, so the + // second open creates the replacement panel B. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + expect(getPanel()).toBe(createdPanels[1]) + + // Panel A's stale dispose handler fires after the replacement is + // tracked; it must not clobber the replacement's ref. + createdPanels[0].onDidDispose.mock.calls[0][0]() + + expect(getPanel()).toBe(createdPanels[1]) + + // Tab-surface commands still reach the provider that owns the + // replacement panel after the stale disposal. + const replacementProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(replacementProvider) + const commandHandlers: Record unknown> = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + commandHandlers[id] = cb + return { dispose: vi.fn() } + }, + ) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: {} as ClineProvider, + }) + await commandHandlers["zoo-code.historyButtonClickedInTab"]() + expect(replacementProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "historyButtonClicked", + }) + }) }) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 5fd5171a83..336111e72e 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode" import delay from "delay" -import type { CommandId } from "@roo-code/types" +import type { CommandId, ExtensionMessage } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Package } from "../shared/package" @@ -105,6 +105,23 @@ export const registerCommands = (options: RegisterCommandOptions) => { // `filePath?: string`, others take none) and VS Code dispatches positional // args dynamically. type CommandCallback = (...args: any[]) => unknown + +// Posts each action in order to the target instance. Failures are logged +// (not thrown) with the handler-specific prefix so a failed post stays +// attributable in the output channel. +const postActions = ( + outputChannel: vscode.OutputChannel, + target: ClineProvider, + actions: readonly NonNullable[], + logPrefix: string, +) => { + for (const action of actions) { + void target + .postMessageToWebview({ type: "action", action }) + .catch((error) => outputChannel.appendLine(`[${logPrefix}] postMessageToWebview failed: ${error}`)) + } +} + const getCommandsMap = ({ context, outputChannel, @@ -150,13 +167,8 @@ const getCommandsMap = ({ settingsButtonClicked: () => { TelemetryService.instance.captureTitleButtonClicked("settings") - void provider - .postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) - // Also explicitly post the visibility message to trigger scroll reliably - void provider - .postMessageToWebview({ type: "action", action: "didBecomeVisible" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) + // Also explicitly post the visibility message to trigger scroll reliably. + postActions(outputChannel, provider, ["settingsButtonClicked", "didBecomeVisible"], "settingsButtonClicked") }, settingsButtonClickedInTab: () => { const tabProvider = getTabProvider() @@ -166,23 +178,17 @@ const getCommandsMap = ({ TelemetryService.instance.captureTitleButtonClicked("settings") - void tabProvider - .postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) - .catch((error) => - outputChannel.appendLine(`[settingsButtonClickedInTab] postMessageToWebview failed: ${error}`), - ) - void tabProvider - .postMessageToWebview({ type: "action", action: "didBecomeVisible" }) - .catch((error) => - outputChannel.appendLine(`[settingsButtonClickedInTab] postMessageToWebview failed: ${error}`), - ) + postActions( + outputChannel, + tabProvider, + ["settingsButtonClicked", "didBecomeVisible"], + "settingsButtonClickedInTab", + ) }, historyButtonClicked: () => { TelemetryService.instance.captureTitleButtonClicked("history") - void provider - .postMessageToWebview({ type: "action", action: "historyButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[historyButtonClicked] postMessageToWebview failed: ${error}`)) + postActions(outputChannel, provider, ["historyButtonClicked"], "historyButtonClicked") }, historyButtonClickedInTab: () => { const tabProvider = getTabProvider() @@ -192,29 +198,17 @@ const getCommandsMap = ({ TelemetryService.instance.captureTitleButtonClicked("history") - void tabProvider - .postMessageToWebview({ type: "action", action: "historyButtonClicked" }) - .catch((error) => - outputChannel.appendLine(`[historyButtonClickedInTab] postMessageToWebview failed: ${error}`), - ) + postActions(outputChannel, tabProvider, ["historyButtonClicked"], "historyButtonClickedInTab") }, marketplaceButtonClicked: () => { - void provider - .postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) - .catch((error) => - outputChannel.appendLine(`[marketplaceButtonClicked] postMessageToWebview failed: ${error}`), - ) + postActions(outputChannel, provider, ["marketplaceButtonClicked"], "marketplaceButtonClicked") }, marketplaceButtonClickedInTab: () => { const tabProvider = getTabProvider() if (!tabProvider) { return } - void tabProvider - .postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) - .catch((error) => - outputChannel.appendLine(`[marketplaceButtonClickedInTab] postMessageToWebview failed: ${error}`), - ) + postActions(outputChannel, tabProvider, ["marketplaceButtonClicked"], "marketplaceButtonClickedInTab") }, newTask: handleNewTask, setCustomStoragePath: async () => { @@ -292,106 +286,128 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit { - // Reuse the tracked tab instead of opening a second one: a repeated - // "Open in editor" click reveals the existing tab's panel. - if (tabPanel) { - const existingProvider = ClineProvider.getInstanceForView(tabPanel) - if (existingProvider) { - await tabPanel.reveal() - await existingProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) - return existingProvider - } - } - - // (This example uses webviewProvider activation event which is necessary to - // deserialize cached webview, but since we use retainContextWhenHidden, we - // don't need to use that event). - // https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts - const contextProxy = await ContextProxy.getInstance(context) - const codeIndexManager = CodeIndexManager.getInstance(context) + const creation = createTabPanelUnlocked({ context, outputChannel }) + pendingTabPanelCreation = creation - // Get the existing MDM service instance to ensure consistent policy enforcement - let mdmService: MdmService | undefined - try { - mdmService = MdmService.getInstance() - } catch (error) { - // MDM service not initialized, which is fine - extension can work without it - mdmService = undefined + try { + return await creation + } finally { + // Clear once settled (success or failure) so the next call starts + // fresh: the reuse path in createTabPanelUnlocked then takes over + // for the tracked panel. Guard the clear so this settlement cannot + // clobber a replacement already stored in the slot. That clobber is + // unreachable in single-threaded settlement order: while the slot + // holds this in-flight creation, every other caller receives that + // same promise (guard above), so no replacement can be stored before + // this finally block runs — the equality check pins the invariant. + // Stryker disable next-line ConditionalExpression: defensive clobber guard, unreachable per the ordering argument above. + if (pendingTabPanelCreation === creation) { + pendingTabPanelCreation = undefined } + } +} - const tabProvider = new ClineProvider(context, outputChannel, "editor", contextProxy, mdmService) - const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) +// The unserialized tab-creation body. Only openClineInNewTab may call it, +// after it has stored the shared in-flight promise. +const createTabPanelUnlocked = async ({ context, outputChannel }: Omit) => { + // Reuse the tracked tab instead of opening a second one: a repeated + // "Open in editor" click reveals the existing tab's panel. + if (tabPanel) { + const existingProvider = ClineProvider.getInstanceForView(tabPanel) + if (existingProvider) { + await tabPanel.reveal() + await existingProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + return existingProvider + } + } - // Check if there are any visible text editors, otherwise open a new group - // to the right. - const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 + // (This example uses webviewProvider activation event which is necessary to + // deserialize cached webview, but since we use retainContextWhenHidden, we + // don't need to use that event). + // https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts + const contextProxy = await ContextProxy.getInstance(context) + const codeIndexManager = CodeIndexManager.getInstance(context) - if (!hasVisibleEditors) { - await vscode.commands.executeCommand("workbench.action.newGroupRight") - } + // Get the existing MDM service instance to ensure consistent policy enforcement + let mdmService: MdmService | undefined + try { + mdmService = MdmService.getInstance() + } catch (error) { + // MDM service unavailable: log the fallback and continue without it. + outputChannel.appendLine(`[openClineInNewTab] MDM service unavailable, continuing without it: ${error}`) + mdmService = undefined + } - const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two + const tabProvider = new ClineProvider(context, outputChannel, "editor", contextProxy, mdmService) + const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) - const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [context.extensionUri], - }) + // Check if there are any visible text editors, otherwise open a new group + // to the right. + const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 - // Save as tab type panel. - setPanel(newPanel, "tab") + if (!hasVisibleEditors) { + await vscode.commands.executeCommand("workbench.action.newGroupRight") + } - // TODO: Use better svg icon with light and dark variants (see - // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). - newPanel.iconPath = { - light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_light.png"), - dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), - } + const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two - await tabProvider.resolveWebviewView(newPanel) + const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [context.extensionUri], + }) - // Add listener for visibility changes to notify webview - newPanel.onDidChangeViewState( - (e) => { - const panel = e.webviewPanel - if (panel.visible) { - panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx - } - }, - null, // First null is for `thisArgs` - context.subscriptions, // Register listener for disposal - ) + // Save as tab type panel. + // Stryker disable next-line StringLiteral: setPanel branches only on type === "sidebar", so any other literal routes to the identical tab-ref assignment + setPanel(newPanel, "tab") - // Handle panel closing events. - newPanel.onDidDispose( - () => { - setPanel(undefined, "tab") - }, - null, - context.subscriptions, // Also register dispose listener - ) + // TODO: Use better svg icon with light and dark variants (see + // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). + newPanel.iconPath = { + light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_light.png"), + dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), + } - // Lock the editor group so clicking on files doesn't open them over the panel. - await delay(100) - await vscode.commands.executeCommand("workbench.action.lockEditorGroup") + await tabProvider.resolveWebviewView(newPanel) - return tabProvider - })() + // Add listener for visibility changes to notify webview + newPanel.onDidChangeViewState( + (e) => { + const panel = e.webviewPanel + if (panel.visible) { + panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx + } + }, + null, // First null is for `thisArgs` + context.subscriptions, // Register listener for disposal + ) + + // Handle panel closing events: clear the tracked ref only if this panel + // is still the tracked one, so a late disposal of an already-replaced + // panel cannot clobber the replacement's ref. + newPanel.onDidDispose( + () => { + if (tabPanel === newPanel) { + // Stryker disable next-line StringLiteral: setPanel branches only on type === "sidebar", so any other literal routes to the identical tab-ref assignment + setPanel(undefined, "tab") + } + }, + null, + context.subscriptions, // Also register dispose listener + ) - pendingTabPanelCreation = creation + // Lock the editor group so clicking on files doesn't open them over the panel. + await delay(100) + await vscode.commands.executeCommand("workbench.action.lockEditorGroup") - try { - return await creation - } finally { - // Clear once settled (success or failure) so the next call starts - // fresh: the reuse path above then takes over for the tracked panel. - pendingTabPanelCreation = undefined - } + return tabProvider } diff --git a/src/package.json b/src/package.json index 6753513658..2b7c018bf4 100644 --- a/src/package.json +++ b/src/package.json @@ -287,6 +287,24 @@ } ] }, + "commandPalette": [ + { + "command": "zoo-code.plusButtonClickedInTab", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + }, + { + "command": "zoo-code.settingsButtonClickedInTab", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + }, + { + "command": "zoo-code.marketplaceButtonClickedInTab", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + }, + { + "command": "zoo-code.historyButtonClickedInTab", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + } + ], "keybindings": [ { "command": "zoo-code.addToContext", From 892bad953bedde8dbe0e6a4e2609f144c121d93e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 5 Sep 2026 15:00:35 +0800 Subject: [PATCH 6/8] feat(provider): persist per-view view-state identity and durable viewStates Each ClineProvider instance now owns a unique viewId (renderContext plus a monotonic counter) and registers a stable viewStateId for durable persistence. - Per-view state buffer (viewLocalState) holds mode / currentApiConfigName / apiConfiguration overrides in memory; saveViewState persists the non-secret subset durably under the active view id, rekeyed to the stable id on registration. - viewStates is stored as a map pruned to the newest 50 entries; writes go through a serialized queue so concurrent provider instances merge without lost updates. - setViewStateId sanitizes ids and rejects "__proto__" so a per-view entry can never be keyed through the Object.prototype setter. - postMessageToWebview no longer awaits the webview ack: a remounted or disposed page never acknowledges, and awaiting would wedge task-critical callers. - History restore falls back to the default mode view-locally instead of writing the shared global mode. - GlobalState gains the "viewStates" key and GLOBAL_STATE_KEYS tracks it. Adds F1a coverage in ClineProvider.spec.ts (viewId uniqueness, saveViewState persistence semantics, loadViewState fallback and failure, pruning, the __proto__ guard) and adapts the two history-restore tests in ClineProvider.sticky-mode.spec.ts to the view-local restore. getState() merging of hydrated per-view values and the remaining view-state suites land in the follow-up (F1b). --- packages/types/src/__tests__/index.test.ts | 5 + packages/types/src/global-settings.ts | 10 + packages/types/src/vscode-extension-host.ts | 1 + src/core/webview/ClineProvider.ts | 391 +++++++++++- .../webview/__tests__/ClineProvider.spec.ts | 575 +++++++++++++++++- .../ClineProvider.sticky-mode.spec.ts | 15 +- src/eslint-suppressions.json | 2 +- 7 files changed, 976 insertions(+), 23 deletions(-) diff --git a/packages/types/src/__tests__/index.test.ts b/packages/types/src/__tests__/index.test.ts index 15441d48fd..b4cee22f8c 100644 --- a/packages/types/src/__tests__/index.test.ts +++ b/packages/types/src/__tests__/index.test.ts @@ -3,6 +3,10 @@ import { GLOBAL_STATE_KEYS } from "../index.js" describe("GLOBAL_STATE_KEYS", () => { + it("should contain registered durable per-view state", () => { + expect(GLOBAL_STATE_KEYS).toContain("viewStates") + }) + it("should contain provider settings keys", () => { expect(GLOBAL_STATE_KEYS).toContain("autoApprovalEnabled") }) @@ -13,6 +17,7 @@ describe("GLOBAL_STATE_KEYS", () => { it("should not contain secret state keys", () => { expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey") + expect(GLOBAL_STATE_KEYS).not.toContain("apiKey") }) it("should contain OpenAI Compatible base URL setting", () => { diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 95f246dbe7..d3bc3efd1a 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -99,6 +99,15 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * Persisted non-secret selections for a stable webview instance. + */ +export const viewStateSchema = z.object({ + mode: z.string().optional(), + currentApiConfigName: z.string().optional(), + updatedAt: z.number().optional(), +}) + /** * GlobalSettings */ @@ -107,6 +116,7 @@ export const globalSettingsSchema = z.object({ currentApiConfigName: z.string().optional(), listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(), pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(), + viewStates: z.record(z.string(), viewStateSchema).optional(), lastShownAnnouncementId: z.string().optional(), customInstructions: z.string().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 5f6b579779..26d9aeb240 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -647,6 +647,7 @@ export interface WebviewMessage { | "openRulesDirectory" | "themeFixtureProbeResponse" text?: string + viewStateId?: string taskId?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 29c32ae9cf..a4583e6bc6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -56,6 +56,7 @@ import { getModelId, isRetiredProvider, providerIdentifiers, + PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -128,6 +129,14 @@ import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" import { PendingEditOperationStore, type PendingEditOperationInput } from "./PendingEditOperationStore" +type PersistedViewState = NonNullable[string] + +/** + * Values that can be held in a view-local state buffer (in-memory) and, for the + * non-secret subset, persisted durably per stable view id. + */ +type ViewLocalStateValues = Partial & Partial + /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts * https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts @@ -183,6 +192,9 @@ export class ClineProvider public static readonly sideBarId = `${Package.name}.SidebarProvider` public static readonly tabPanelId = `${Package.name}.TabPanelProvider` private static activeInstances: Set = new Set() + private static nextViewId = 0 + private static readonly MAX_PERSISTED_VIEW_STATES = 50 + private static persistedViewStateWriteQueue: Promise = Promise.resolve() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< @@ -307,6 +319,25 @@ export class ClineProvider */ private clineMessagesSeq = 0 + /** + * Unique identifier for this provider instance's view. + * Based on renderContext and a monotonically increasing counter to ensure uniqueness across multiple instances. + */ + public readonly viewId: string + + /** + * Stable identifier for persisted per-view state keys. + * Defaults to viewId until the webview reports its VS Code-persisted id. + */ + private viewStateId: string + + /** + * Local state buffer for this specific view instance. + * Used to isolate mode, apiConfiguration, and other fields from the shared ContextProxy singleton + * when running in parallel (multi-tab) mode. + */ + private viewLocalState: Partial = {} + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "sep-2026-v3.82.0-gateway-portability-free-models" // v3.82.0 portable Zoo Gateway keys, free MiniMax-M3, and new models @@ -321,14 +352,17 @@ export class ClineProvider mdmService?: MdmService, ) { super() + // Initialize viewId based on renderContext and monotonically increasing instance identifier for uniqueness. + // activeInstances is used for visibility/iteration checks, so we keep tracking instances separately. + this.viewId = `${renderContext}-${ClineProvider.nextViewId++}` + this.viewStateId = this.viewId + ClineProvider.activeInstances.add(this) this.currentWorkspacePath = getWorkspacePath() this.pendingEditOperations = new PendingEditOperationStore( ClineProvider.PENDING_OPERATION_TIMEOUT_MS, (message) => this.log(message), ) - ClineProvider.activeInstances.add(this) - this.mdmService = mdmService void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) @@ -359,6 +393,9 @@ export class ClineProvider await this.postStateToWebviewWithoutClineMessages() }) + // Load initial state from global state into viewLocalState buffer after dependencies used by getState are ready. + void this.loadViewState() + // Initialize MCP Hub through the singleton manager McpServerManager.getInstance(this.context, this) .then((hub) => { @@ -509,6 +546,217 @@ export class ClineProvider } } + /** + * Reads the registered viewStates map, returning a defensive copy. + * When fresh is set, the map is read directly from globalState (bypassing the + * ContextProxy cache) so serialized writes never observe a stale in-memory value. + */ + private getPersistedViewStates(options: { fresh?: boolean } = {}): Record { + const viewStates = options.fresh + ? this.context.globalState.get("viewStates") + : this.contextProxy.getValue("viewStates") + + if (!viewStates || typeof viewStates !== "object" || Array.isArray(viewStates)) { + return {} + } + + return { ...viewStates } + } + + /** + * Persists this view's non-secret selections through the serialized write queue. + * The write re-reads the map fresh and merges into the existing entry, removing the + * entry entirely when nothing persistable remains, so concurrent views cannot clobber it. + * The entry is keyed by the view id active when the change was made. Writes captured + * while the provider still holds its temporary (pre-launch) id persist under that id + * and are re-keyed to the stable view id when the webview registers one, so a change + * that lands before the launch message stays durable instead of being lost. + */ + private async savePersistedViewState(values: Partial): Promise { + // Capture the id at change time: a write belongs to the view that was active + // when the change was made, even if a newer id is registered while it is queued. + const viewStateId = this.viewStateId + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + const current = states[viewStateId] ?? {} + const next: PersistedViewState = { ...current } + + if ("mode" in values) { + if (values.mode === undefined || values.mode === null) { + delete next.mode + } else { + next.mode = values.mode + } + } + + if ("currentApiConfigName" in values) { + if (values.currentApiConfigName === undefined || values.currentApiConfigName === null) { + delete next.currentApiConfigName + } else { + next.currentApiConfigName = values.currentApiConfigName + } + } + + if (!next.mode && !next.currentApiConfigName) { + delete states[viewStateId] + } else { + next.updatedAt = values.updatedAt ?? Date.now() + states[viewStateId] = next + } + + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Removes the given view's entry from the registered viewStates map. + * Runs through the serialized write queue to avoid racing concurrent view-state writes. + */ + private async clearPersistedViewState(viewStateId = this.viewStateId): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + delete states[viewStateId] + await this.contextProxy.setValue("viewStates", states) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Keeps only the most recently updated entries of the persisted view states map, + * bounded by MAX_PERSISTED_VIEW_STATES so the global key cannot grow unboundedly. + */ + private prunePersistedViewStates(states: Record): Record { + return Object.fromEntries( + Object.entries(states) + .sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, ClineProvider.MAX_PERSISTED_VIEW_STATES), + ) + } + + /** + * Re-keys this provider's temporary pre-launch viewStates entry to the newly + * registered stable id so pre-launch writes become durable under the stable key + * instead of orphaning under a session-local temporary id. Only the provider's own + * temporary id is eligible: an entry under a previously registered stable id belongs + * to that webview's storage and is left alone. When the stable entry already exists + * it wins and the temporary entry is dropped, because temporary ids are session + * counters that can collide across window reloads. Runs through the serialized write + * queue like every other viewStates mutation. + */ + private async rekeyPersistedViewStateEntry(nextViewStateId: string): Promise { + const previousViewStateId = this.viewId + + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + const previous = states[previousViewStateId] + + if (!previous) { + return + } + + delete states[previousViewStateId] + + if (!states[nextViewStateId]) { + states[nextViewStateId] = previous + } + + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Registers this provider's stable view identifier and loads any persisted selections it owns. + * The identifier is sanitized so it remains a safe object key in the shared viewStates map. + */ + public async setViewStateId(viewStateId: string | undefined): Promise { + const normalizedViewStateId = viewStateId?.trim().replace(/[^A-Za-z0-9_-]/g, "_") + + if ( + !normalizedViewStateId || + normalizedViewStateId === this.viewStateId || + // Reject "__proto__": writing states["__proto__"] would go through the + // Object.prototype setter and be silently dropped by the later spread. + normalizedViewStateId === "__proto__" + ) { + return + } + + this.viewStateId = normalizedViewStateId + + // Re-key any durable entry written under the temporary pre-launch id before + // loading, so the load sees the view's own pre-registration selections. + await this.rekeyPersistedViewStateEntry(this.viewStateId) + + await this.loadViewState() + } + + /** + * Loads non-secret persisted selections from the registered viewStates map. + * Missing entries are intentionally left unset so getState() falls back to shared ContextProxy values. + */ + private async loadViewState(): Promise { + // Capture the id this load is for: a newer id registered while an async + // profile lookup is in flight must not be overwritten by this stale load. + const loadedForViewId = this.viewStateId + try { + const persisted = this.getPersistedViewStates()[loadedForViewId] + const loadedState: Partial = {} + + if (persisted?.mode) { + loadedState.mode = persisted.mode as Mode + } + + if (persisted?.currentApiConfigName) { + loadedState.currentApiConfigName = persisted.currentApiConfigName + + try { + const { name: _name, ...apiConfiguration } = await this.providerSettingsManager.getProfile({ + name: persisted.currentApiConfigName, + }) + loadedState.apiConfiguration = apiConfiguration as ProviderSettings + } catch (error) { + this.log( + `[loadViewState] Unable to resolve API profile '${persisted.currentApiConfigName}' for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + if (this.viewStateId !== loadedForViewId) { + this.log(`[loadViewState] Discarding stale state for superseded view id ${loadedForViewId}`) + return + } + + this.viewLocalState = loadedState + this.log(`[loadViewState] Loaded state for viewId ${this.viewId}`) + } catch (error) { + this.log( + `[loadViewState] Error loading state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Saves a single view-local state value. The in-memory buffer is always updated; the + * non-secret subset (mode, currentApiConfigName) is persisted durably under the view + * id active when the change was made, re-keyed to the stable id on registration. + */ + public async saveViewState( + key: K, + value: ViewLocalStateValues[K] | undefined, + ): Promise { + await this._saveViewLocalStateFromMutation({ [key]: value } as ViewLocalStateValues) + + this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`) + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -1265,7 +1513,10 @@ export class ClineProvider historyItem.mode = defaultModeSlug } - await this.updateGlobalState("mode", historyItem.mode) + // Persist the restored mode through this view's per-view pin rather than the + // shared global: a global write would leak the restored mode into other views + // in parallel mode, and a buffer-only write would be lost after a reload. + await this.saveViewState("mode", historyItem.mode) // Load the saved API config for the restored mode if it exists. // Skip mode-based profile activation if historyItem.apiConfigName exists, @@ -1478,11 +1729,20 @@ export class ClineProvider return } - try { - await this.view?.webview.postMessage(message) - } catch { - // View disposed, drop message silently + const webview = this.view?.webview + if (!webview) { + return } + + // Dispatch without awaiting the renderer ack: VS Code settles postMessage only when the + // webview page acknowledges the message, and a page reload or view dispose in flight + // orphans that promise forever. Awaiting it could wedge every caller on the task critical + // path (e.g. the trailing postStateToWebview in handleModeSwitchUnlocked gates the next + // turn after a mode switch). Message ordering is enforced by the message seq, not the ack. + // Promise.resolve() normalizes non-promise returns (e.g. test doubles) before the catch. + void Promise.resolve(webview.postMessage(message)).catch(() => { + // Swallow: postMessage rejects when the webview is disposed in flight. + }) } public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { @@ -3200,6 +3460,7 @@ export class ClineProvider public async setValue(key: K, value: RooCodeSettings[K]) { await this.contextProxy.setValue(key, value) + await this._saveViewLocalStateFromMutation({ [key]: value }) } public getValue(key: K) { @@ -3207,11 +3468,115 @@ export class ClineProvider } public getValues() { - return this.contextProxy.getValues() + return { ...this.contextProxy.getValues(), ...this.viewLocalState } } public async setValues(values: RooCodeSettings) { - await this.contextProxy.setValues(values) + const sanitizedValues = { ...values } + + if ( + typeof sanitizedValues.mode === "string" && + !getModeBySlug(sanitizedValues.mode, await this.customModesManager.getCustomModes()) + ) { + // An unknown mode (e.g. from an API payload) must not be persisted: a new Task + // would read it from getState() and persist it into task history. + this.log(`[ClineProvider#setValues] Ignoring unknown mode "${sanitizedValues.mode}"`) + delete sanitizedValues.mode + } + + await this.contextProxy.setValues(sanitizedValues) + await this._saveViewLocalStateFromMutation(sanitizedValues) + } + + /** + * Persists the view-local subset of a ContextProxy mutation, then updates the in-memory + * viewLocalState buffer. Persistence is awaited first so a failed durable write cannot + * leave the local cache ahead of the persisted state. + */ + private async _saveViewLocalStateFromMutation( + values: Partial & Partial, + ): Promise { + await this._persistViewLocalStateFromMutation(values) + this._updateViewLocalStateFromMutation(values) + } + + /** + * Update or invalidate viewLocalState when ContextProxy is mutated via setValues, setValue, + * profile upsert/activation/deletion, or resetState. This ensures the local cache stays in + * sync with global state changes that would otherwise be invisible behind mergedStateValues. + */ + private _updateViewLocalStateFromMutation(values: Partial & Partial): void { + if ("mode" in values) { + const val = values.mode + if (val === undefined || val === null) { + delete this.viewLocalState.mode + } else { + this.viewLocalState.mode = val + } + } + + if ("currentApiConfigName" in values) { + const val = values.currentApiConfigName + if (val === undefined || val === null) { + delete this.viewLocalState.currentApiConfigName + } else { + this.viewLocalState.currentApiConfigName = val + } + } + + if ("apiConfiguration" in values) { + const val = values.apiConfiguration + if (val === undefined || val === null) { + delete this.viewLocalState.apiConfiguration + } else { + this.viewLocalState.apiConfiguration = val + } + } else if (PROVIDER_SETTINGS_KEYS.some((key) => key in values)) { + const providerSettingsUpdate = PROVIDER_SETTINGS_KEYS.reduce((acc, key) => { + if (key in values) { + return { ...acc, [key]: values[key as keyof RooCodeSettings] } + } + + return acc + }, {} as ProviderSettings) + + this.viewLocalState.apiConfiguration = + "apiProvider" in providerSettingsUpdate + ? providerSettingsUpdate + : { + ...(this.viewLocalState.apiConfiguration ?? {}), + ...providerSettingsUpdate, + } + } + } + + /** + * Writes the durably persisted subset of a mutation (mode and currentApiConfigName) + * into the registered viewStates map for this view. + */ + private async _persistViewLocalStateFromMutation( + values: Partial & Partial, + ): Promise { + const persistedValues: Partial = {} + + if ("mode" in values) { + persistedValues.mode = values.mode as PersistedViewState["mode"] + } + + if ("currentApiConfigName" in values) { + persistedValues.currentApiConfigName = values.currentApiConfigName + } + + if ("mode" in persistedValues || "currentApiConfigName" in persistedValues) { + await this.savePersistedViewState(persistedValues) + } + } + + /** + * Clear view-local state cache so that getState() falls back to ContextProxy defaults. + */ + private _clearViewLocalState(): void { + this.viewLocalState = {} } // dev @@ -3240,6 +3605,14 @@ export class ClineProvider } await this.contextProxy.resetAllState() + + // Clear view-local state cache so getState() falls back to ContextProxy defaults. + this._clearViewLocalState() + + // Clear this view's persisted entry too, so the reset selections are not + // re-applied from the durable viewStates pin after a reload. + await this.clearPersistedViewState() + await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 84b80cf945..b1672084f3 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -12,6 +12,7 @@ import { type ClineMessage, type ExtensionMessage, type ExtensionState, + type RooCodeSettings, type WebviewMessage, ORGANIZATION_ALLOW_ALL, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, @@ -27,6 +28,7 @@ import { setTtsEnabled } from "../../../utils/tts" import { ContextProxy } from "../../config/ContextProxy" import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" +import { t } from "../../../i18n" import { ClineProvider } from "../ClineProvider" import { webviewMessageHandler } from "../webviewMessageHandler" @@ -592,7 +594,7 @@ describe("ClineProvider", () => { }) test("does not reload full model details when the LM Studio model is already loaded", async () => { - vi.mocked(hasLoadedFullDetails).mockReturnValue(true) + vi.mocked(hasLoadedFullDetails).mockReturnValueOnce(true) await provider.performPreparationTasks({ apiConfiguration: { @@ -785,6 +787,26 @@ describe("ClineProvider", () => { await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() }) + test("postMessageToWebview does not await the webview ack", async () => { + await provider.resolveWebviewView(mockWebviewView) + + let releaseAck!: () => void + const ack = new Promise((resolve) => { + releaseAck = resolve + }) + mockPostMessage.mockImplementationOnce(() => ack) + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + + // The caller must not wait for the renderer ack: a webview page remounted or disposed + // while the post is in flight never acknowledges it, and awaiting that promise would + // wedge every caller on the task critical path. + await provider.postMessageToWebview(message) + + expect(mockPostMessage).toHaveBeenCalledWith(message) + releaseAck() + }) + describe("theme fixture probes", () => { const fixture = { themeId: "Default Dark Modern", @@ -988,6 +1010,541 @@ describe("ClineProvider", () => { expect(state.taskHistory).toEqual([historyItem]) }) + describe("viewId uniqueness", () => { + it("should assign unique viewId to each instance", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // Each instance should have a unique viewId + expect(provider1.viewId).toBeDefined() + expect(provider2.viewId).toBeDefined() + expect(provider1.viewId).not.toBe(provider2.viewId) + + await provider1.dispose() + await provider2.dispose() + }) + + it("should have viewId in correct format: {renderContext}-{instanceCount}", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + expect(provider.viewId).toMatch(/^sidebar-\d+$/) + + await provider.dispose() + }) + + it("should increment instance count for each new instance", async () => { + const provider1 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // First editor instance should be "editor-0" (or next available) + // Second editor instance should have a different number + const num1 = parseInt(provider1.viewId.split("-")[1]!) + const num2 = parseInt(provider2.viewId.split("-")[1]!) + + expect(num2).toBeGreaterThan(num1) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("saveViewState", () => { + it("should update viewLocalState and persist mode through registered viewStates", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue") + await provider["setViewStateId"]("stable-sidebar-view") + + await provider.saveViewState("mode", "architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + expect(contextProxySpy).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + "stable-sidebar-view": expect.objectContaining({ + mode: "architect", + updatedAt: expect.any(Number), + }), + }), + ) + expect(contextProxySpy).not.toHaveBeenCalledWith("__view_state_stable-sidebar-view_mode", expect.anything()) + + await provider.dispose() + }) + + it("should update viewLocalState and persist currentApiConfigName through registered viewStates", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("currentApiConfigName", "my-profile") + + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "my-profile" }, + }) + + await provider.dispose() + }) + + it("should update viewLocalState for apiConfiguration without persisting provider settings or secrets", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + const testApiConfig = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "claude-3.5-sonnet", + openRouterApiKey: "secret-key", + } + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("apiConfiguration", testApiConfig) + + expect(provider["viewLocalState"].apiConfiguration).toEqual(testApiConfig) + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("should clear local override when saveViewState receives undefined", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.saveViewState("mode", "architect") + expect(provider["viewLocalState"].mode).toBe("architect") + + await provider.saveViewState("mode", undefined) + + expect(Object.prototype.hasOwnProperty.call(provider["viewLocalState"], "mode")).toBe(false) + + await provider.dispose() + }) + + it("should clear the currentApiConfigName override when saveViewState receives undefined", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.saveViewState("currentApiConfigName", "my-profile") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + + await provider.saveViewState("currentApiConfigName", undefined) + + expect(Object.prototype.hasOwnProperty.call(provider["viewLocalState"], "currentApiConfigName")).toBe(false) + + await provider.dispose() + }) + + it("should not update viewLocalState when durable view-state persistence fails", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerAccess = provider as unknown as { + setViewStateId: (viewStateId: string) => Promise + saveViewState: (key: keyof ExtensionState, value: unknown) => Promise + viewLocalState: Partial + } + vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(new Error("persist failed")) + + await providerAccess.setViewStateId("stable-sidebar-view") + + await expect(providerAccess.saveViewState("mode", "architect")).rejects.toThrow("persist failed") + expect(providerAccess.viewLocalState).not.toHaveProperty("mode") + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("should merge concurrent persisted updates from separate provider instances without lost viewStates", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await provider1["setViewStateId"]("stable-sidebar-view") + await provider2["setViewStateId"]("stable-editor-view") + + await Promise.all([ + provider1.saveViewState("mode", "architect"), + provider2.saveViewState("currentApiConfigName", "editor-profile"), + ]) + + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { currentApiConfigName: "editor-profile" }, + }) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("loadViewState", () => { + it("should keep viewLocalState empty when no stable per-view values exist", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await vi.waitFor(() => { + expect(provider["viewLocalState"]).toEqual({}) + }) + + const state = await provider.getState() + // No per-view entry exists and the proxy's global-state cache is empty + // (initialize() is never called in this fixture; only "taskHistory" passes + // through to the context store), so getState() falls back to the shared + // defaults: mode "code" (defaultModeSlug) and currentApiConfigName "default". + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).toBe("default") + + await provider.dispose() + }) + + it("should log and keep existing viewLocalState when loadViewState fails", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider, "log") + + provider["viewLocalState"] = { mode: "architect" } + vi.spyOn(provider.contextProxy, "getValue").mockImplementation(() => { + throw new Error("load failed") + }) + + await provider["loadViewState"]() + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Error loading state")) + + await provider.dispose() + }) + }) + + describe("persisted view state pruning", () => { + it("should keep the newest 50 persisted view states", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: index }, + ]), + ) + + const pruned = provider["prunePersistedViewStates"](states) + + expect(Object.keys(pruned)).toHaveLength(50) + expect(pruned["view-54"]).toBeDefined() + expect(pruned["view-5"]).toBeDefined() + expect(pruned["view-4"]).toBeUndefined() + + await provider.dispose() + }) + }) + + describe("setViewStateId", () => { + it('should ignore "__proto__" and keep the temporary viewId', async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("__proto__") + + // "__proto__" is rejected before assignment so a per-view entry can never be + // keyed through the Object.prototype setter: the temporary id stays active and + // nothing is persisted under the reserved name. + expect(provider["viewStateId"]).toBe(provider.viewId) + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + }) + + describe("view state persistence edge cases", () => { + it("should read viewStates from the ContextProxy cache when not fresh", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + expect(provider["getPersistedViewStates"]()).toEqual({ "stable-sidebar-view": { mode: "architect" } }) + await provider.dispose() + }) + + it("should treat a corrupted non-object viewStates value as an empty map", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // A string in storage is corrupt: the fresh-read guard must not spread it. + mockContext.globalState.update("viewStates", "corrupted-storage-value") + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + await provider.dispose() + }) + + it("should merge saved fields, drop cleared fields and delete emptied entries", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider, "log") + const save = provider.saveViewState.bind(provider) as (key: string, value: unknown) => Promise + const states = () => mockContext.globalState.get>("viewStates") ?? {} + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "profile-a") + const merged = states()["stable-sidebar-view"] + expect(merged).toMatchObject({ mode: "architect", currentApiConfigName: "profile-a" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Saved mode for viewId")) + await save("mode", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + currentApiConfigName: "profile-a", + updatedAt: expect.any(Number), + }) + await save("mode", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("mode") + await provider.saveViewState("mode", "architect") + await save("currentApiConfigName", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + mode: "architect", + updatedAt: expect.any(Number), + }) + await provider.saveViewState("currentApiConfigName", "profile-c") + await provider.saveViewState("mode", "architect") + expect(states()["stable-sidebar-view"]).toMatchObject({ + mode: "architect", + currentApiConfigName: "profile-c", + }) + await save("currentApiConfigName", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("currentApiConfigName") + await save("mode", null) + expect(states()["stable-sidebar-view"]).toBeUndefined() + expect(provider["viewLocalState"]).toStrictEqual({}) // buffer ends fully cleared + await provider.dispose() + }) + + it("should rekey a pre-launch entry under the temporary id to the registered stable id", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // Seed storage directly (bypassing the ContextProxy cache) so only the fresh read sees it. + mockContext.globalState.update("viewStates", { [provider.viewId]: { mode: "architect", updatedAt: 1 } }) + await provider["setViewStateId"]("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + await provider.dispose() + }) + + it("should keep the stable entry and drop the temporary entry when both exist", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + mockContext.globalState.update("viewStates", { + [provider.viewId]: { mode: "temp-mode", updatedAt: 1 }, + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider["setViewStateId"]("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider.dispose() + }) + + it("should clear only this view's entry without clobbering an entry only storage knows about", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + await provider["setViewStateId"]("stable-sidebar-view") + // The cache only knows this view's entry; storage gains an extra view directly. + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + mockContext.globalState.update("viewStates", { + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { mode: "code" }, + }) + await provider["clearPersistedViewState"]() + expect(mockContext.globalState.get("viewStates")).toEqual({ "stable-editor-view": { mode: "code" } }) + await provider.dispose() + }) + + it("should prune by updatedAt regardless of insertion order", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: (index * 7) % 55 }, + ]), + ) + const pruned = provider["prunePersistedViewStates"](states) + expect(Object.keys(pruned)).toHaveLength(50) + // view-1/view-54 survive the true newest-50 selection; view-8 (updatedAt 1) does not. + expect(pruned["view-1"]).toBeDefined() + expect(pruned["view-54"]).toBeDefined() + expect(pruned["view-8"]).toBeUndefined() + await provider.dispose() + }) + + it("should sanitize, reject blank and undefined ids, and no-op on the active id", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("a b/c") + expect(provider["viewStateId"]).toBe("a_b_c") + await provider["setViewStateId"](undefined) + await provider["setViewStateId"](" ") + expect(provider["viewStateId"]).toBe("a_b_c") + logSpy.mockClear() + await provider["setViewStateId"]("a_b_c") + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should load persisted mode, profile name and resolved profile into viewLocalState", async () => { + const writer = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "architect") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider, "log") + const getProfileSpy = vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { getProfile: getProfileSpy } + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "my-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + expect(getProfileSpy).toHaveBeenCalledWith({ name: "my-profile" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await writer.dispose() + await provider.dispose() + }) + + it("should log a successful empty load when no persisted entry exists", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).toEqual({}) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted profile name and log when the profile lookup fails", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a failing test double. + provider.providerSettingsManager = { getProfile: vi.fn().mockRejectedValue(new Error("profile missing")) } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Unable to resolve API profile 'my-profile'")) + await provider.dispose() + }) + + it("should discard a stale load when the viewStateId changes during the profile lookup", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a test double that registers a newer id. + provider.providerSettingsManager = { + getProfile: vi.fn().mockImplementation(() => { + provider["viewStateId"] = "superseded-view" + return Promise.resolve({ name: "my-profile", apiProvider: providerIdentifiers.openrouter }) + }), + } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + const staleMsg = expect.stringContaining("Discarding stale state for superseded view id") + expect(logSpy).toHaveBeenCalledWith(staleMsg) + await provider.dispose() + }) + + it("should persist known modes, ignore unknown modes and pass through non-string modes", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; narrow it to the slugs under test. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + slug === "refactor" ? { slug } : undefined) as typeof modesModule.getModeBySlug) + try { + await provider.setValues({ mode: "refactor" }) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + await provider.setValues({ mode: "bogus-mode" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring unknown mode "bogus-mode"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + // A non-string mode bypasses the slug validation (double assertion: the type excludes non-strings). + await provider.setValues({ mode: 42 } as unknown as RooCodeSettings) + expect(mockContext.globalState.get("mode")).toBe(42) + expect(provider["viewLocalState"].mode).toBe(42) + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + + it("should apply setValue mutations to global state and keep or clear the right buffer fields", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const apiConfiguration = { apiProvider: providerIdentifiers.openrouter } + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider.saveViewState("apiConfiguration", apiConfiguration) + // A mutation of an unrelated key reaches global state without dropping buffered fields. + await provider.setValue("writeDelayMs", 500) + expect(mockContext.globalState.get("writeDelayMs")).toBe(500) + expect(provider.getValues().writeDelayMs).toBe(500) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toBe(apiConfiguration) + await provider.setValue("mode", undefined) + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + await provider.setValue("currentApiConfigName", undefined) + expect(provider["viewLocalState"]).not.toHaveProperty("currentApiConfigName") + await provider.dispose() + }) + + it("should build the buffered apiConfiguration from provider settings keys", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + provider["viewLocalState"] = { apiConfiguration: { openRouterApiKey: "key-1" } } + await provider.setValues({ apiProvider: providerIdentifiers.openrouter }) + expect(provider["viewLocalState"].apiConfiguration).toStrictEqual({ + apiProvider: providerIdentifiers.openrouter, + }) + await provider.setValues({ openRouterModelId: "model-x" }) + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await provider.dispose() + }) + + it("should remove the buffered apiConfiguration when it is cleared", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const save = provider.saveViewState.bind(provider) as (key: string, value: unknown) => Promise + await provider.saveViewState("apiConfiguration", { apiProvider: providerIdentifiers.openrouter }) + await provider.saveViewState("apiConfiguration", undefined) + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + await provider.saveViewState("apiConfiguration", { apiProvider: providerIdentifiers.openrouter }) + await save("apiConfiguration", null) + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + await provider.dispose() + }) + + it("should clear viewLocalState and the persisted entry when resetting state", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace customModesManager with a test double (the real reset writes to disk). + provider.customModesManager = { resetCustomModes: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } + // The modal answer is a string label; the last-typed vscode overload expects a MessageItem. + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue( + t("common:answers.yes") as unknown as vscode.MessageItem, + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "architect") + await provider.resetState() + expect(provider["viewLocalState"]).toEqual({}) + expect(mockContext.globalState.get("viewStates")).toEqual({}) + await provider.dispose() + }) + }) + describe("postStateToWebviewThrottled", () => { beforeEach(() => { vi.useFakeTimers() @@ -2497,8 +3054,10 @@ describe("ClineProvider", () => { expect(mockCustomModesManager.getCustomModes).toHaveBeenCalled() expect(getModeBySlug).toHaveBeenCalledWith("non-existent-mode", expect.any(Array)) - // Verify fallback to default mode - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "code") + // Verify fallback to default mode, view-locally: history restore no longer + // writes the shared global mode + expect(provider["viewLocalState"].mode).toBe("code") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "code") expect(logSpy).toHaveBeenCalledWith( "Mode 'non-existent-mode' from history no longer exists. Falling back to default mode 'code'.", ) @@ -2570,8 +3129,9 @@ describe("ClineProvider", () => { expect(mockCustomModesManager.getCustomModes).toHaveBeenCalled() expect(getModeBySlug).toHaveBeenCalledWith("custom-mode", expect.any(Array)) - // Verify mode was preserved - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "custom-mode") + // Verify mode was preserved view-locally (no shared global mode write) + expect(provider["viewLocalState"].mode).toBe("custom-mode") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "custom-mode") expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("no longer exists")) // Verify history item mode was not changed @@ -2618,8 +3178,9 @@ describe("ClineProvider", () => { // Initialize with history item await provider.createTaskWithHistoryItem(historyItem) - // Verify mode was preserved - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was preserved view-locally (no shared global mode write) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") // Verify history item mode was not changed expect(historyItem.mode).toBe("architect") diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index fedfa13030..414c368aad 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -472,14 +472,15 @@ describe("ClineProvider - Sticky Mode", () => { mode: "architect", // Saved mode } - // Mock updateGlobalState to track mode updates - const updateGlobalStateSpy = vi.spyOn(provider as any, "updateGlobalState").mockResolvedValue(undefined) + // Register a stable view id so the durable per-view write is persisted + await provider["setViewStateId"]("stable-test-view") // Initialize task with history item await provider.createTaskWithHistoryItem(historyItem) - // Verify mode was restored via updateGlobalState - expect(updateGlobalStateSpy).toHaveBeenCalledWith("mode", "architect") + // Verify mode was restored into the view-local pin (no shared global write) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") }) it("should use current mode if history item has no saved mode", async () => { @@ -760,9 +761,11 @@ describe("ClineProvider - Sticky Mode", () => { // Restore the task from history await provider.createTaskWithHistoryItem(historyItem) - // Verify that the mode was restored + // Verify that the mode was restored into this view's durable pin. The + // getState() merge of hydrated per-view values lands with the F1b follow-up. + expect(provider["viewLocalState"].mode).toBe("architect") + const state = await provider.getState() - expect(state.mode).toBe("architect") // Verify that the API configuration was also restored expect(state.currentApiConfigName).toBe("architect-config") diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 544886c2d7..335ce8aeb2 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1036,7 +1036,7 @@ }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 37 + "count": 36 } }, "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { From 02608aaacfdb7fe4f49ff5824714184041e9659d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 00:39:16 +0800 Subject: [PATCH 7/8] fix(provider): track in-flight view-state mutations per field and exclude viewStates from settings transfer --- src/core/config/ContextProxy.ts | 3 + .../config/__tests__/ContextProxy.spec.ts | 16 +++ .../config/__tests__/importExport.spec.ts | 45 +++++++++ src/core/config/importExport.ts | 8 ++ src/core/webview/ClineProvider.ts | 34 ++++++- .../webview/__tests__/ClineProvider.spec.ts | 99 +++++++++++++++++++ 6 files changed, 204 insertions(+), 1 deletion(-) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 97d4104afc..38c2c9ce99 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -36,6 +36,9 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({ taskHistory: true, listApiConfigMeta: true, currentApiConfigName: true, + // Per-view selection state is machine-local: it keeps flowing through the + // normal runtime and pruning paths but must not transfer between settings. + viewStates: true, }) export class ContextProxy { diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 2319a6b1a5..d389d31337 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -721,4 +721,20 @@ Output only the summary of the conversation so far, without any additional comme expect(customSupportPromptsUpdateCalls.length).toBe(0) }) }) + + describe("export", () => { + it("should exclude viewStates from the exported settings", async () => { + await proxy.setValue("viewStates", { + "stable-sidebar-view": { mode: "architect", currentApiConfigName: "profile-a", updatedAt: 1 }, + }) + await proxy.setValue("customInstructions", "global instructions") + + const exported = await proxy.export() + + // Per-view selection state is machine-local and must never transfer + // between settings, while ordinary global settings keep round-tripping. + expect(exported).not.toHaveProperty("viewStates") + expect(exported?.customInstructions).toBe("global instructions") + }) + }) }) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 6a99adaa7c..c15c103be7 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -332,6 +332,51 @@ describe("importExport", () => { ]) }) + it("should not apply imported viewStates to the context proxy", async () => { + const fileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "test", + apiConfigs: { + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, + }, + }, + globalSettings: { + mode: "code", + viewStates: { + "stable-sidebar-view": { + mode: "architect", + currentApiConfigName: "profile-a", + updatedAt: 1, + }, + }, + }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(fileContent) + + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "test", id: "test-id", apiProvider: providerIdentifiers.openai }, + { name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic }, + ]) + + const result = await importSettingsFromPath("/mock/path/settings.json", { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(result.success).toBe(true) + // Per-view selection state is machine-local: importing settings must not + // apply another machine's view pins, while other settings round-trip. + expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code" }) + expect(result).not.toHaveProperty("globalSettings.viewStates") + }) + it("should return success: false when file content is invalid", async () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 7b3b5aa231..399d5c1507 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -97,6 +97,14 @@ function sanitizeGlobalSettings(rawGlobalSettings: unknown): { for (const [key, rawValue] of Object.entries(rawGlobalSettings)) { const path = `globalSettings.${key}` + + // Per-view selection state is machine-local: it round-trips through the + // normal runtime and pruning paths, but importing it would pin selections + // from another machine's views on this one. + if (key === "viewStates") { + continue + } + const schema = globalSettingsShape[key as keyof GlobalSettings] if (!schema) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a4583e6bc6..0130266b06 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -701,6 +701,8 @@ export class ClineProvider /** * Loads non-secret persisted selections from the registered viewStates map. * Missing entries are intentionally left unset so getState() falls back to shared ContextProxy values. + * Fields mutated while the async profile lookup is in flight are reapplied on top of the + * loaded state, field by field, so in-flight user selections are not clobbered by the load. */ private async loadViewState(): Promise { // Capture the id this load is for: a newer id registered while an async @@ -710,6 +712,11 @@ export class ClineProvider const persisted = this.getPersistedViewStates()[loadedForViewId] const loadedState: Partial = {} + // Snapshot the in-memory buffer before the async profile lookup. The + // mutation paths update viewLocalState in place, so a shallow copy is + // what makes fields mutated during the load window observable below. + const preLoadBuffer = { ...this.viewLocalState } + if (persisted?.mode) { loadedState.mode = persisted.mode as Mode } @@ -734,7 +741,32 @@ export class ClineProvider return } - this.viewLocalState = loadedState + // Reapply only the fields mutated while the load was in flight: untouched + // fields keep the persisted values authoritative, and the pre-load buffer is + // never merged wholesale so stale temporary-id state or a cleared field cannot + // override the stable persisted state. + const postLoadBuffer = this.viewLocalState + const mergedState: Partial = { ...loadedState } + + if (postLoadBuffer.mode !== preLoadBuffer.mode && postLoadBuffer.mode !== undefined) { + mergedState.mode = postLoadBuffer.mode + } + + if ( + postLoadBuffer.currentApiConfigName !== preLoadBuffer.currentApiConfigName && + postLoadBuffer.currentApiConfigName !== undefined + ) { + mergedState.currentApiConfigName = postLoadBuffer.currentApiConfigName + } + + if ( + postLoadBuffer.apiConfiguration !== preLoadBuffer.apiConfiguration && + postLoadBuffer.apiConfiguration !== undefined + ) { + mergedState.apiConfiguration = postLoadBuffer.apiConfiguration + } + + this.viewLocalState = mergedState this.log(`[loadViewState] Loaded state for viewId ${this.viewId}`) } catch (error) { this.log( diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b1672084f3..8f1daff948 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1452,6 +1452,105 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("should reapply fields mutated while the load is in flight and keep persisted values for untouched fields", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider, "log") + let resolveProfile: (value: { + name: string + apiProvider: string + openRouterModelId: string + }) => void = () => {} + // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. + provider.providerSettingsManager = { + getProfile: vi + .fn() + .mockImplementation( + () => + new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( + (resolve) => (resolveProfile = resolve), + ), + ), + } + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Selections made while the profile lookup is in flight must survive the load. + await provider.saveViewState("mode", "architect") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + // Dirty fields win over the loaded state; the untouched field keeps the persisted value. + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-y" }, + }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted mode authoritative when the pre-load buffer is untouched", async () => { + const writer = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "code") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }), + } + // A pre-load buffer write that was never persisted must not be merged over the load. + provider["viewLocalState"] = { mode: "architect" } + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"].mode).toBe("code") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await writer.dispose() + await provider.dispose() + }) + + it("should not resurrect a field cleared mid-load from the pre-load buffer", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + let resolveProfile: (value: { name: string }) => void = () => {} + // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. + provider.providerSettingsManager = { + getProfile: vi + .fn() + .mockImplementation(() => new Promise<{ name: string }>((resolve) => (resolveProfile = resolve))), + } + await provider.saveViewState("currentApiConfigName", "cfg-a") + provider["viewLocalState"] = { ...provider["viewLocalState"], mode: "architect" } + const load = provider["setViewStateId"]("stable-sidebar-view") + + // The user clears the mode while the load is in flight. + await provider.saveViewState("mode", undefined) + resolveProfile({ name: "cfg-a" }) + await load + + // The cleared field must stay absent rather than keeping the persisted or + // pre-load value; the untouched field keeps the persisted value. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-a") + await provider.dispose() + }) + it("should persist known modes, ignore unknown modes and pass through non-string modes", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) const logSpy = vi.spyOn(provider, "log") From e064d77dd744d4969b5cf023481e89aaf39885c0 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 01:16:21 +0800 Subject: [PATCH 8/8] fix(provider): restore previous view-state id when registration persistence fails --- src/core/webview/ClineProvider.ts | 19 ++++++++++--- .../webview/__tests__/ClineProvider.spec.ts | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0130266b06..982aa3be45 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -689,13 +689,24 @@ export class ClineProvider return } + const previousViewStateId = this.viewStateId + this.viewStateId = normalizedViewStateId - // Re-key any durable entry written under the temporary pre-launch id before - // loading, so the load sees the view's own pre-registration selections. - await this.rekeyPersistedViewStateEntry(this.viewStateId) + try { + // Re-key any durable entry written under the temporary pre-launch id before + // loading, so the load sees the view's own pre-registration selections. + await this.rekeyPersistedViewStateEntry(this.viewStateId) - await this.loadViewState() + await this.loadViewState() + } catch (error) { + // A persistence failure must not leave the provider holding an id that was + // never registered: restore the previous id so a later launch retries the + // registration and the load instead of the guard above early-returning for + // the failed id. + this.viewStateId = previousViewStateId + throw error + } } /** diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 8f1daff948..2f0e52826f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1256,6 +1256,33 @@ describe("ClineProvider", () => { await provider.dispose() }) + + it("restores the previous view id when the registration write fails so a later launch retries", async () => { + const contextProxy = new ContextProxy(mockContext) + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", contextProxy) + // Seed a pre-launch entry under the temporary id so the re-key has real work to do. + mockContext.globalState.update("viewStates", { [provider.viewId]: { mode: "architect", updatedAt: 1 } }) + + const setValueSpy = vi.spyOn(contextProxy, "setValue").mockRejectedValue(new Error("storage down")) + + await expect(provider["setViewStateId"]("stable-sidebar-view")).rejects.toThrow("storage down") + + // The failed id must not stick: the provider keeps its previous (temporary) + // id so a later launch retries registration and the load instead of the + // guard early-returning for an id that was never persisted. + expect(provider["viewStateId"]).toBe(provider.viewId) + + // A later retry succeeds once the storage write works again, and the + // pre-launch entry lands under the registered id. + setValueSpy.mockRestore() + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewStateId"]).toBe("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + + await provider.dispose() + }) }) describe("view state persistence edge cases", () => {