From bf30669286cbaf586531e569046f664387a4563d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 5 Sep 2026 02:18:07 +0800 Subject: [PATCH 01/34] 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 7088560700..1e1ee6a5a7 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 da98be291b..1418fd4938 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -40,7 +40,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, @@ -48,13 +53,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 @@ -90,21 +104,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") @@ -113,44 +141,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") @@ -176,8 +234,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) { @@ -221,6 +282,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 874e2f8f08..7969b1dfd9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -910,6 +910,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 b5f304fe56..bcbc1c56e2 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -583,6 +583,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 0931ca779d..cb2e971a1d 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 854af59849cec21a7901dc170db15b983a55a53c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 7 Sep 2026 15:46:00 +0800 Subject: [PATCH 02/34] 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 | 170 ++++++++++-------- .../webview/__tests__/ClineProvider.spec.ts | 5 +- src/eslint-suppressions.json | 5 - 4 files changed, 123 insertions(+), 87 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 1e1ee6a5a7..bd570ab8f4 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 1418fd4938..8338d4b149 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -31,6 +31,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 @@ -282,87 +288,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 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 bcbc1c56e2..1374e42fe1 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -584,9 +584,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 583485c628..92eca69ec1 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 cbf0edb4a2ea03068f1f167b40a367eee7906734 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 7 Sep 2026 16:13:46 +0800 Subject: [PATCH 03/34] 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 bd570ab8f4..96e00419e1 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 6cc68ef45144c400369447e550006d6f2d9e3caa Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 00:11:44 +0800 Subject: [PATCH 04/34] 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 96e00419e1..0f34a93b88 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 8338d4b149..1e8e20a5ce 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" @@ -104,6 +104,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, @@ -149,13 +166,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() @@ -165,23 +177,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() @@ -191,29 +197,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 () => { @@ -291,106 +285,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 cb2e971a1d..ae390ca5bb 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 d7453eb9a80e34d5088cf1077a96c5886badb22b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 04:22:14 +0800 Subject: [PATCH 05/34] test(activate): pin tracked tab identity against the created panel Replace the weak toBeDefined() assertion in the dispose spec with an identity check against the panel returned during creation, per the CodeRabbit actionable comment on this PR (review run 7c4cfeb3-6dd9-4615- 9a58-70cfc705eca2). The tracked tab is now pinned with toBe(panel) before the dispose assertions, so a wrong or duplicated tracked panel fails the suite instead of passing a defined-only check. Upstream: Zoo-Code-Org/Zoo-Code#1528 (vps2 F0) --- src/activate/__tests__/registerCommands.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 0f34a93b88..1836a2b98c 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -753,12 +753,15 @@ describe("openClineInNewTab", () => { 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() - + // Retain the panel returned during creation and pin the tracked tab + // against it with identity (not a weak defined check), so a wrong or + // duplicated tracked panel fails before the dispose assertions. const panel = (vscode.window.createWebviewPanel as Mock).mock.results[0].value as { onDidChangeViewState: Mock onDidDispose: Mock } + expect(getPanel()).toBe(panel) + const stateHandler = panel.onDidChangeViewState.mock.calls[0][0] as (event: { webviewPanel: { visible: boolean; webview: { postMessage: (message: unknown) => void } } }) => void From 199d07217799375425dd2653485bbe7fd8e5dbe3 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 04:51:48 +0800 Subject: [PATCH 06/34] test(activate): pin the tracked tab panel passed to getInstanceForView Retain the tracked tab panel in the InTab handler cases and assert that getInstanceForView was called with that exact panel, per the CodeRabbit actionable comment on this PR (review run 4afe1273-8739-4235-90d3-311db5f6ccb9, inline comment 3952466254 on the tabHandlerCases spec). A handler resolving any other view now fails instead of passing on the stubbed provider result alone; the same identity pin is applied to plusButtonClickedInTab. Upstream: Zoo-Code-Org/Zoo-Code#1528 (vps2 F0) --- src/activate/__tests__/registerCommands.spec.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 1836a2b98c..600697fc12 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -257,11 +257,17 @@ describe("registerCommands handlers", () => { "$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") + // Retain the tracked tab panel and pin the instance lookup + // against its identity: a handler that resolved the sidebar view + // or any other view must fail instead of passing on the stubbed + // provider result alone. + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) handlers[command]() + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) for (const action of actions) { expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action }) } @@ -539,11 +545,15 @@ describe("registerCommands handlers", () => { evictCurrentTask: vi.fn().mockResolvedValue(undefined), refreshWorkspace: vi.fn().mockResolvedValue(undefined), } - setPanel({} as vscode.WebviewPanel, "tab") + // Same identity pin as the other InTab cases: the eviction must run + // against the provider resolved from the exact tracked tab panel. + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) await handlers["zoo-code.plusButtonClickedInTab"]() + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") expect(mockTabProvider.evictCurrentTask).toHaveBeenCalledTimes(1) expect(mockTabProvider.refreshWorkspace).toHaveBeenCalledTimes(1) From c1bf94739536105d4358c66965f781f8d501329b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 5 Sep 2026 15:00:35 +0800 Subject: [PATCH 07/34] 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 54ae7af089..48b1b94d1b 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -109,6 +109,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 */ @@ -117,6 +126,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 2f1222f8ee..96a6908f7d 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -648,6 +648,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 7969b1dfd9..170fd7e2b5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -57,6 +57,7 @@ import { getModelId, isRetiredProvider, providerIdentifiers, + PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -135,6 +136,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 @@ -203,6 +212,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< @@ -322,6 +334,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 = "oct-2026-v3.86.0-models-aborts-tool-streaming" // v3.86.0 models, aborts, and tool/UI streaming fixes @@ -336,14 +367,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) @@ -368,6 +402,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) => { @@ -490,6 +527,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 */ @@ -1259,7 +1507,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, @@ -1475,11 +1726,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 { @@ -3178,6 +3438,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) { @@ -3185,11 +3446,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 @@ -3218,6 +3583,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 1374e42fe1..d39ef15f8e 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -14,6 +14,7 @@ import { type ClineMessage, type ExtensionMessage, type ExtensionState, + type RooCodeSettings, type WebviewMessage, ORGANIZATION_ALLOW_ALL, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, @@ -31,6 +32,7 @@ import { ContextProxy } from "../../config/ContextProxy" import { WorkspaceIndexingEnablementManager } from "../../../services/code-index/workspace-indexing-enablement-manager" import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" +import { t } from "../../../i18n" import { ClineProvider } from "../ClineProvider" import { webviewMessageHandler } from "../webviewMessageHandler" @@ -608,7 +610,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: { @@ -853,6 +855,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", @@ -1056,6 +1078,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() @@ -2642,8 +3199,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'.", ) @@ -2715,8 +3274,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 @@ -2763,8 +3323,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 55e26a9667..32edd75063 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -478,14 +478,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 () => { @@ -762,9 +763,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 92eca69ec1..b7907f6a1c 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": 27 + "count": 36 } }, "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { From 1e952c53f8c2ef040bc98496fc28798d74357d7f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 00:39:16 +0800 Subject: [PATCH 08/34] 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 0287b26511..c8a71d5c62 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 3c213fedf4..3351a20d62 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -98,6 +98,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 170fd7e2b5..18430c4e5c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -682,6 +682,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 @@ -691,6 +693,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 } @@ -715,7 +722,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 d39ef15f8e..2968c03efd 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1520,6 +1520,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 b16a9e5228daae11aefdd2516d8cd306c0f87d15 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 01:16:21 +0800 Subject: [PATCH 09/34] 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 18430c4e5c..7e8079bd2a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -670,13 +670,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 2968c03efd..d8fc8e48b1 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1324,6 +1324,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", () => { From a3c60645f12864a0321c9cbc09ed344583f8c2bf Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 08:32:35 +0800 Subject: [PATCH 10/34] fix(provider): reject invalid view-state modes and sync profile mutations through the view-local buffer --- src/core/webview/ClineProvider.ts | 49 ++- .../webview/__tests__/ClineProvider.spec.ts | 311 +++++++++++++++++- 2 files changed, 332 insertions(+), 28 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7e8079bd2a..e8e27a3fb0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -710,7 +710,15 @@ export class ClineProvider const preLoadBuffer = { ...this.viewLocalState } if (persisted?.mode) { - loadedState.mode = persisted.mode as Mode + // A persisted mode may reference a custom mode that was deleted after it was + // pinned: restore it only when the slug still resolves, so a stale slug cannot + // shadow the shared mode from getState(). + const customModes = await this.customModesManager.getCustomModes() + if (getModeBySlug(persisted.mode, customModes)) { + loadedState.mode = persisted.mode as Mode + } else { + this.log(`[loadViewState] Ignoring unknown persisted mode "${persisted.mode}"`) + } } if (persisted?.currentApiConfigName) { @@ -2214,7 +2222,10 @@ export class ClineProvider // I left the original implementation in just to be safe. await Promise.all([ this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.updateGlobalState("currentApiConfigName", name), + // Route through setValue so the in-memory viewLocalState buffer tracks the + // activated profile: a plain global write would leave a stale loaded + // currentApiConfigName shadowing the new value in getValues(). + this.setValue("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), ]) @@ -2256,12 +2267,19 @@ export class ClineProvider const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) + // Write the other settings in one bulk call, then route the current-profile write + // through setValue so the in-memory viewLocalState buffer tracks the activated + // profile: a plain ContextProxy write would leave a stale loaded + // currentApiConfigName shadowing the new value in getValues(). + const { currentApiConfigName: _previousApiConfigName, ...globalSettingsWithoutCurrent } = globalSettings + await this.contextProxy.setValues({ - ...globalSettings, - currentApiConfigName: profileToActivate, + ...globalSettingsWithoutCurrent, listApiConfigMeta: entries, }) + await this.setValue("currentApiConfigName", profileToActivate) + await this.postStateToWebview() } @@ -2329,7 +2347,10 @@ export class ClineProvider // See `upsertProviderProfile` for a description of what this is doing. await Promise.all([ this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.contextProxy.setValue("currentApiConfigName", name), + // Route through setValue so the in-memory viewLocalState buffer tracks the + // activated profile: a plain ContextProxy write would leave a stale loaded + // currentApiConfigName shadowing the new value in getValues(). + this.setValue("currentApiConfigName", name), this.contextProxy.setProviderSettings(providerSettings), ]) } @@ -3495,14 +3516,16 @@ export class ClineProvider public async setValues(values: RooCodeSettings) { 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 + if (sanitizedValues.mode !== undefined) { + // An unknown or non-string 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. + if ( + typeof sanitizedValues.mode !== "string" || + !getModeBySlug(sanitizedValues.mode, await this.customModesManager.getCustomModes()) + ) { + this.log(`[ClineProvider#setValues] Ignoring invalid mode "${String(sanitizedValues.mode)}"`) + delete sanitizedValues.mode + } } await this.contextProxy.setValues(sanitizedValues) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index d8fc8e48b1..fcf1f837df 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1556,19 +1556,23 @@ describe("ClineProvider", () => { 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), - ), - ), - } + const getProfileSpy = vi + .fn() + .mockImplementation( + () => + new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( + (resolve) => (resolveProfile = resolve), + ), + ) + // @ts-ignore - The spy-backed double only needs the stalled getProfile member. + provider.providerSettingsManager = { getProfile: getProfileSpy } await provider.saveViewState("currentApiConfigName", "cfg-a") const load = provider["setViewStateId"]("stable-sidebar-view") + // Let the stalled lookup begin so the in-flight mutations and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // Selections made while the profile lookup is in flight must survive the load. await provider.saveViewState("mode", "architect") await provider.saveViewState("apiConfiguration", { @@ -1621,6 +1625,32 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("should not restore a persisted mode whose custom mode no longer exists", 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; drop the pinned slug. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + slug === "deleted-custom-mode" ? undefined : originalMode) as typeof modesModule.getModeBySlug) + try { + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "deleted-custom-mode") + // Reload the persisted entry: the custom mode was deleted in the meantime. + await provider["loadViewState"]() + // The stale slug must not be restored into the buffer. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Ignoring unknown persisted mode "deleted-custom-mode"'), + ) + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + 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 = () => {} @@ -1646,7 +1676,166 @@ describe("ClineProvider", () => { await provider.dispose() }) - it("should persist known modes, ignore unknown modes and pass through non-string modes", async () => { + it("should reapply an independently mutated mode when the load settles", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + let resolveProfile: (value: { + name: string + apiProvider: string + openRouterModelId: string + }) => void = () => {} + // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. + const getProfileSpy = vi + .fn() + .mockImplementation( + () => + new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( + (resolve) => (resolveProfile = resolve), + ), + ) + // @ts-ignore - The spy-backed double only needs the stalled getProfile member. + provider.providerSettingsManager = { getProfile: getProfileSpy } + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // Only the mode is mutated while the profile lookup is in flight. + await provider.saveViewState("mode", "architect") + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + await provider.dispose() + }) + + it("should reapply an independently mutated profile name when the load settles", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + let resolveProfile: (value: { + name: string + apiProvider: string + openRouterModelId: string + }) => void = () => {} + // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. + const getProfileSpy = vi + .fn() + .mockImplementation( + () => + new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( + (resolve) => (resolveProfile = resolve), + ), + ) + // @ts-ignore - The spy-backed double only needs the stalled getProfile member. + provider.providerSettingsManager = { getProfile: getProfileSpy } + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // The profile name changes while the lookup is in flight; the load still + // resolves the name persisted at load start. + await provider.saveViewState("currentApiConfigName", "cfg-b") + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + // The in-flight selection wins over the loaded state. + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-b") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + await provider.dispose() + }) + + it("should reapply an independently mutated apiConfiguration when the load settles", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + let resolveProfile: (value: { + name: string + apiProvider: string + openRouterModelId: string + }) => void = () => {} + // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. + const getProfileSpy = vi + .fn() + .mockImplementation( + () => + new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( + (resolve) => (resolveProfile = resolve), + ), + ) + // @ts-ignore - The spy-backed double only needs the stalled getProfile member. + provider.providerSettingsManager = { getProfile: getProfileSpy } + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // Only the apiConfiguration is mutated while the profile lookup is in flight. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-a") + await provider.dispose() + }) + + it("should keep every persisted field authoritative when the load is untouched", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + let resolveProfile: (value: { + name: string + apiProvider: string + openRouterModelId: string + }) => void = () => {} + // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. + const getProfileSpy = vi + .fn() + .mockImplementation( + () => + new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( + (resolve) => (resolveProfile = resolve), + ), + ) + // @ts-ignore - The spy-backed double only needs the stalled getProfile member. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + await provider.saveViewState("mode", "code") + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // No mutation while the lookup is in flight: the persisted values must win. + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"]).toEqual({ + mode: "code", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + await provider.dispose() + }) + + it("should persist known modes and reject unknown or 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). @@ -1661,13 +1850,15 @@ describe("ClineProvider", () => { 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(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid 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). + // A non-string mode must be rejected before persistence (double assertion: the type + // excludes non-strings), so it must not reach global state or the buffer. await provider.setValues({ mode: 42 } as unknown as RooCodeSettings) - expect(mockContext.globalState.get("mode")).toBe(42) - expect(provider["viewLocalState"].mode).toBe(42) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "42"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") } finally { modesModule.getModeBySlug.mockReturnValue(originalMode) } @@ -1739,6 +1930,96 @@ describe("ClineProvider", () => { }) }) + describe("provider profile mutations", () => { + it("should sync the view-local buffer when activating a profile over a loaded view state", async () => { + const writer = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("currentApiConfigName", "old-profile") + + const profile: ProviderSettingsEntry = { + name: "new-profile", + id: "new-id", + apiProvider: providerIdentifiers.openrouter, + } + + const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue(profile), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + getProfile: vi + .fn() + .mockResolvedValue({ + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.anthropic, + }), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider.getValues().currentApiConfigName).toBe("old-profile") + + await provider.activateProviderProfile({ name: "new-profile" }) + + // The buffer must track the activated profile so getValues() agrees with the proxy. + expect(provider.getValues().currentApiConfigName).toBe("new-profile") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("new-profile") + await writer.dispose() + await provider.dispose() + }) + + it("should sync the view-local buffer when creating and activating a profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const profile: ProviderSettingsEntry = { + name: "fresh-profile", + id: "fresh-id", + apiProvider: providerIdentifiers.openrouter, + } + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("fresh-id"), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.setValue("currentApiConfigName", "stale-profile") + + await provider.upsertProviderProfile("fresh-profile", { apiProvider: providerIdentifiers.openrouter }) + + expect(provider.getValues().currentApiConfigName).toBe("fresh-profile") + await provider.dispose() + }) + + it("should sync the view-local buffer when deleting the current profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "old-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // The fallback profile must replace the deleted one in both the proxy and the buffer. + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("keeper-profile") + await provider.dispose() + }) + }) + describe("postStateToWebviewThrottled", () => { beforeEach(() => { vi.useFakeTimers() From 3aec562a16cf77a02f35205c56429e64d53b9194 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 08:32:57 +0800 Subject: [PATCH 11/34] fix(package): move command palette entries to the commandPalette menu contribution --- src/package.json | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/package.json b/src/package.json index ae390ca5bb..7027f516a9 100644 --- a/src/package.json +++ b/src/package.json @@ -285,26 +285,26 @@ "group": "overflow@2", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" } + ], + "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" + } ] }, - "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 016c221e195cfc58401138b85237eab41920bde5 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 11:11:49 +0800 Subject: [PATCH 12/34] fix(webview): sync view-local state with mode and profile mutations and target tab-instance commands Reapply in-flight view-local fields with Object.is identity so a field cleared during the load window stays cleared; route mode switches through setValue so the in-memory buffer and durable write agree, with rollback on failure; refresh cross-instance view-local state on profile upsert, activate and delete and re-pin the buffer after a delete; point focusInput and active-panel re-registration at the tracked tab provider and panel; log dropped webview postMessage failures with the message type; pin tab-instance, focusInput and active-panel identity in the registerCommands tests and type the mdm double in the provider spec. --- .../__tests__/registerCommands.spec.ts | 83 ++++++- src/activate/registerCommands.ts | 22 +- src/core/webview/ClineProvider.ts | 206 +++++++++++++++--- .../webview/__tests__/ClineProvider.spec.ts | 152 +++++-------- src/eslint-suppressions.json | 2 +- src/package.json | 8 +- 6 files changed, 337 insertions(+), 136 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 600697fc12..043059c260 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -267,7 +267,9 @@ describe("registerCommands handlers", () => { handlers[command]() - expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) + // Identity pin: the lookup must receive the exact tracked panel + // object, not a different object that merely compares equal. + expect((ClineProvider.getInstanceForView as Mock).mock.calls[0]![0]).toBe(tabPanel) for (const action of actions) { expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action }) } @@ -386,12 +388,28 @@ describe("registerCommands handlers", () => { expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) - it("focusInput does not post when a tab panel is tracked alongside the sidebar", async () => { + it("focusInput does not post when a tab panel is tracked without a live tab instance", async () => { setPanel({} as vscode.WebviewView, "sidebar") setPanel({} as vscode.WebviewPanel, "tab") await handlers["zoo-code.focusInput"]() + // The tab takes selection priority, so the sidebar must not receive + // the message; with no live tab instance there is no other target. + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("focusInput posts the focus message on the tab instance when a tab panel is tracked", async () => { + const mockTabProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + setPanel({} as vscode.WebviewView, "sidebar") + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + await handlers["zoo-code.focusInput"]() + + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) @@ -553,7 +571,9 @@ describe("registerCommands handlers", () => { await handlers["zoo-code.plusButtonClickedInTab"]() - expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) + // Identity pin: the eviction must run against the provider resolved + // from the exact tracked panel object, not a merely-equal stub. + expect((ClineProvider.getInstanceForView as Mock).mock.calls[0]![0]).toBe(tabPanel) expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") expect(mockTabProvider.evictCurrentTask).toHaveBeenCalledTimes(1) expect(mockTabProvider.refreshWorkspace).toHaveBeenCalledTimes(1) @@ -655,6 +675,63 @@ describe("openClineInNewTab", () => { expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) }) + it("re-points the tracked tab ref at the panel that becomes active", async () => { + // Panel A is created first and tracked... + const panelA = { + marker: "panel-A", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } as unknown as vscode.WebviewPanel + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelA) + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + // ...then panel B is created, which re-points the tracked tab ref. + const panelB = { + marker: "panel-B", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } as unknown as vscode.WebviewPanel + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelB) + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + // Activating A must reassign the tracked tab ref to A's panel... + const stateChange = (panelA.onDidChangeViewState as Mock).mock.calls[0]![0] as (e: { + webviewPanel: vscode.WebviewPanel + }) => void + stateChange({ webviewPanel: { ...panelA, active: true, visible: true } }) + + // ...so plusButtonClickedInTab targets A's provider, not B's. + const mockProviderA = { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), + } + ;(ClineProvider.getInstanceForView as Mock).mockImplementation((view: unknown) => + (view as { marker?: string }).marker === "panel-A" ? mockProviderA : undefined, + ) + const handlers = new Map unknown>() + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + handlers.set(id, cb) + return { dispose: vi.fn() } + }, + ) + const mockSidebarProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: mockSidebarProvider as unknown as ClineProvider, + }) + + await handlers.get("zoo-code.plusButtonClickedInTab")!() + + expect(mockProviderA.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(mockProviderA.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "chatButtonClicked" }) + expect(mockProviderA.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) + }) + it("falls back to an undefined MdmService when MdmService.getInstance throws", async () => { ;(MdmService.getInstance as Mock).mockImplementation(() => { throw new Error("MDM service not initialized") diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 1e8e20a5ce..64b29d5978 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -234,11 +234,15 @@ const getCommandsMap = ({ try { await focusPanel(tabPanel, 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) { + // Post to the surface focusPanel selected: the tab takes + // selection priority, so the sidebar is targeted only when no + // tab panel is tracked. + if (tabPanel) { + const tabProvider = getTabProvider() + if (tabProvider) { + await tabProvider.postMessageToWebview({ type: "action", action: "focusInput" }) + } + } else if (sidebarPanel) { await provider.postMessageToWebview({ type: "action", action: "focusInput" }) } } catch (error) { @@ -382,6 +386,14 @@ const createTabPanelUnlocked = async ({ context, outputChannel }: Omit { const panel = e.webviewPanel + // Re-point the tracked tab ref at the panel the user is actually + // looking at: several tab panels can stay visible at once, but + // only the active one is the current tab, and the title-bar + // commands must resolve that instance, not the last created one. + if (panel.active) { + // Stryker disable next-line StringLiteral: setPanel only distinguishes "sidebar"; any other value routes to the tab-ref assignment + setPanel(panel, "tab") + } if (panel.visible) { panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e8e27a3fb0..5f5c4b9fb8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -741,29 +741,38 @@ export class ClineProvider return } - // 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. + // Reapply the buffer fields that changed while the load was in flight, + // tracking the change instead of testing against undefined: a field cleared + // during the load window must stay cleared (the loaded value must not + // resurrect it), and a field written to a new value must win over it. + // Untouched fields keep the persisted values authoritative, and the + // pre-load buffer is never merged wholesale so stale temporary-id state + // 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 (!Object.is(preLoadBuffer.mode, postLoadBuffer.mode)) { + if (postLoadBuffer.mode === undefined) { + delete mergedState.mode + } else { + mergedState.mode = postLoadBuffer.mode + } } - if ( - postLoadBuffer.currentApiConfigName !== preLoadBuffer.currentApiConfigName && - postLoadBuffer.currentApiConfigName !== undefined - ) { - mergedState.currentApiConfigName = postLoadBuffer.currentApiConfigName + if (!Object.is(preLoadBuffer.currentApiConfigName, postLoadBuffer.currentApiConfigName)) { + if (postLoadBuffer.currentApiConfigName === undefined) { + delete mergedState.currentApiConfigName + } else { + mergedState.currentApiConfigName = postLoadBuffer.currentApiConfigName + } } - if ( - postLoadBuffer.apiConfiguration !== preLoadBuffer.apiConfiguration && - postLoadBuffer.apiConfiguration !== undefined - ) { - mergedState.apiConfiguration = postLoadBuffer.apiConfiguration + if (!Object.is(preLoadBuffer.apiConfiguration, postLoadBuffer.apiConfiguration)) { + if (postLoadBuffer.apiConfiguration === undefined) { + delete mergedState.apiConfiguration + } else { + mergedState.apiConfiguration = postLoadBuffer.apiConfiguration + } } this.viewLocalState = mergedState @@ -1788,8 +1797,13 @@ export class ClineProvider // 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(() => { + void Promise.resolve(webview.postMessage(message)).catch((error) => { // Swallow: postMessage rejects when the webview is disposed in flight. + // Log the dropped message type so a wedged webview channel is diagnosable + // instead of silently losing state updates. + this.log( + `[postMessageToWebview] dropped message type=${message.type}: ${error instanceof Error ? error.message : String(error)}`, + ) }) } @@ -2070,7 +2084,36 @@ export class ClineProvider } } - await this.updateGlobalState("mode", newMode) + // A cancelled or timed-out switch must not write the mode or emit + // ModeChanged: check the mutation signal right before the durable write. + if (signal?.aborted) { + return + } + + // setValue (not the deprecated updateGlobalState) so the in-memory viewLocalState + // buffer stays in sync with the durable global write: getValues() merges + // viewLocalState on top of the ContextProxy values, so an unsynced stale + // restored mode would otherwise shadow the fresh switch for consumers. + // If the durable write fails, roll the shared write back so getValues() + // cannot mix a fresh shared mode with the stale pre-switch buffer. + const previousMode = this.getValue("mode") + try { + await this.setValue("mode", newMode) + } catch (error) { + try { + await this.contextProxy.setValue("mode", previousMode) + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back shared mode after persistence failure: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + this.log( + `[handleModeSwitch] Failed to persist mode "${newMode}": ${error instanceof Error ? error.message : String(error)}`, + ) + throw error + } this.emit(RooCodeEventName.ModeChanged, newMode) @@ -2228,8 +2271,18 @@ export class ClineProvider this.setValue("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), + // setProviderSettings writes the shared store directly, bypassing the + // view-local mutation path: also refresh this view's buffer so a stale + // loaded apiConfiguration cannot keep shadowing the new settings in + // getState(). + this._saveViewLocalStateFromMutation(providerSettings), ]) + // Other live views may have buffered this profile's settings earlier; + // refresh them so their getState() cannot report the updated profile's + // name with stale settings. + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) + // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) @@ -2267,19 +2320,44 @@ export class ClineProvider const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) - // Write the other settings in one bulk call, then route the current-profile write - // through setValue so the in-memory viewLocalState buffer tracks the activated - // profile: a plain ContextProxy write would leave a stale loaded - // currentApiConfigName shadowing the new value in getValues(). - const { currentApiConfigName: _previousApiConfigName, ...globalSettingsWithoutCurrent } = globalSettings + // Write only the profile list back: replaying the full settings snapshot + // captured above would also rewrite unrelated keys (including viewStates, + // which ClineProvider mutates directly in storage for concurrent views) + // with this view's stale cached copy. + await this.contextProxy.setValue("listApiConfigMeta", entries) - await this.contextProxy.setValues({ - ...globalSettingsWithoutCurrent, - listApiConfigMeta: entries, - }) + // Resolve the surviving profile's settings so this view and any other + // live view still pinned to the deleted profile can be re-pinned with + // a matching configuration. + let survivingSettings: ProviderSettings | undefined + try { + const { name: _survivingName, ...settings } = await this.providerSettingsManager.getProfile({ + name: profileToActivate, + }) + survivingSettings = settings as ProviderSettings + } catch (error) { + this.log( + `[deleteProviderProfile] Unable to resolve API profile '${profileToActivate}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } await this.setValue("currentApiConfigName", profileToActivate) + if (profileToDelete.name === globalSettings.currentApiConfigName && survivingSettings) { + // The deleted profile was the active one, so the shared provider keys + // and this view's buffer still carry its settings; replace both so + // getState() reports the surviving profile's configuration. + await this.contextProxy.setProviderSettings(survivingSettings) + await this._saveViewLocalStateFromMutation(survivingSettings) + } + + // Re-pin other live views still buffered on the deleted profile: their + // buffer and durable viewStates entry would otherwise keep serving the + // deleted profile's name and configuration. + await this.rePinViewLocalStateForDeletedProfile(profileToDelete.name, profileToActivate, survivingSettings) + await this.postStateToWebview() } @@ -2352,7 +2430,17 @@ export class ClineProvider // currentApiConfigName shadowing the new value in getValues(). this.setValue("currentApiConfigName", name), this.contextProxy.setProviderSettings(providerSettings), + // setProviderSettings writes the shared store directly, bypassing the + // view-local mutation path: also refresh this view's buffer so a stale + // loaded apiConfiguration cannot keep shadowing the new settings in + // getState(). + this._saveViewLocalStateFromMutation(providerSettings), ]) + + // Other live views may have buffered this profile's settings earlier; + // refresh them so their getState() cannot report the activated profile's + // name with stale settings. + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } const { mode } = await this.getState() @@ -2379,6 +2467,70 @@ export class ClineProvider } } + /** + * Refresh the view-local apiConfiguration buffer of the other live views + * pinned to the given profile. An upsert/activation rewrites the profile's + * settings in the shared store and the store-backed manager, but a view + * whose buffer loaded the profile earlier keeps shadowing the stale + * settings in its getState() until its own next mutation. The originating + * view refreshes its buffer at the mutation site itself. + */ + private async refreshViewLocalStateForUpdatedProfile( + name: string, + providerSettings: ProviderSettings, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => instance !== this && instance["viewLocalState"].currentApiConfigName === name, + ) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + await instance["_saveViewLocalStateFromMutation"]({ apiConfiguration: providerSettings }) + await instance.postStateToWebview() + }), + ) + } + + /** + * Re-pin the other live views whose buffer still names a deleted profile: + * without this their in-memory buffer and durable viewStates entry keep + * serving the deleted profile's name and configuration on top of the + * surviving shared state. Each affected view's durable entry is re-pinned + * through the serialized write queue, so the rename survives reloads. + */ + private async rePinViewLocalStateForDeletedProfile( + deletedProfileName: string, + replacementName: string, + replacementSettings: ProviderSettings | undefined, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => instance !== this && instance["viewLocalState"].currentApiConfigName === deletedProfileName, + ) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + const values: Partial & Partial = { + currentApiConfigName: replacementName, + } + + if (replacementSettings) { + values.apiConfiguration = replacementSettings + } + + await instance["_saveViewLocalStateFromMutation"](values) + await instance.postStateToWebview() + }), + ) + } + async updateCustomInstructions(instructions?: string) { // User may be clearing the field. await this.updateGlobalState("customInstructions", instructions || undefined) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index fcf1f837df..e0c5542c02 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -36,6 +36,7 @@ import { t } from "../../../i18n" import { ClineProvider } from "../ClineProvider" import { webviewMessageHandler } from "../webviewMessageHandler" +import type { MdmService } from "../../../services/mdm/MdmService" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" @@ -417,6 +418,35 @@ afterAll(() => { vi.restoreAllMocks() }) +/** + * Minimal profile shape the stalled getProfile double resolves to. The + * settings fields are optional so a lookup that resolves name-only (a + * profile with no configured provider) also type-checks. + */ +type StalledProfile = { + name: string + apiProvider?: string + openRouterModelId?: string +} + +/** + * Swap the provider's ProviderSettingsManager for a double whose getProfile + * stalls until the test resolves it, so tests can mutate view state while + * loadViewState is in flight. The double only backs getProfile, the member + * loadViewState awaits; the documented @ts-ignore replaces the per-test + * suppressions the inlined copies used. + */ +function stallProviderSettingsProfile(provider: ClineProvider) { + let resolveProfile: (value: StalledProfile) => void = () => {} + const getProfileSpy = vi.fn(() => new Promise((resolve) => (resolveProfile = resolve))) + // @ts-ignore - Reassign the readonly providerSettingsManager for the test; the double only backs getProfile. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // Return a stable wrapper around the closure binding: resolveProfile is + // reassigned to the pending promise's resolver once getProfile is called, + // so returning the variable directly would hand the test the initial no-op. + return { getProfile: getProfileSpy, resolveProfile: (value: StalledProfile) => resolveProfile(value) } +} + describe("ClineProvider", () => { beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { @@ -940,10 +970,13 @@ describe("ClineProvider", () => { }) test("postStateToWebview does not force action navigation for non-compliant MDM state", async () => { + // Structural double: the post path only reads these two members, and + // MdmService cannot be constructed as a plain object, so a double + // assertion is the last-resort cast here. const mdmService = { requiresCloudAuth: vi.fn().mockReturnValue(true), isCompliant: vi.fn().mockReturnValue({ compliant: false, reason: "auth required" }), - } as any + } as unknown as MdmService provider = new ClineProvider( mockContext, @@ -954,7 +987,9 @@ describe("ClineProvider", () => { ) const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockImplementation(async () => undefined) - vi.spyOn(provider as any, "getStateToPostToWebview").mockResolvedValue({ version: "1.0.0" }) + vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue({ + version: "1.0.0", + } as unknown as ExtensionState) await provider.postStateToWebview() @@ -1550,22 +1585,7 @@ describe("ClineProvider", () => { 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. - const getProfileSpy = vi - .fn() - .mockImplementation( - () => - new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( - (resolve) => (resolveProfile = resolve), - ), - ) - // @ts-ignore - The spy-backed double only needs the stalled getProfile member. - provider.providerSettingsManager = { getProfile: getProfileSpy } + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) await provider.saveViewState("currentApiConfigName", "cfg-a") const load = provider["setViewStateId"]("stable-sidebar-view") @@ -1653,17 +1673,19 @@ describe("ClineProvider", () => { 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))), - } + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + // Persist a mode too: without it the load has no mode to resurrect, so the + // cleared-field assertion below would pass even if the loaded value clobbered + // the in-flight clear. await provider.saveViewState("currentApiConfigName", "cfg-a") + await provider.saveViewState("mode", "code") provider["viewLocalState"] = { ...provider["viewLocalState"], mode: "architect" } const load = provider["setViewStateId"]("stable-sidebar-view") + // Let the stalled lookup begin so the in-flight clear and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // The user clears the mode while the load is in flight. await provider.saveViewState("mode", undefined) resolveProfile({ name: "cfg-a" }) @@ -1678,22 +1700,7 @@ describe("ClineProvider", () => { it("should reapply an independently mutated mode when the load settles", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - let resolveProfile: (value: { - name: string - apiProvider: string - openRouterModelId: string - }) => void = () => {} - // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. - const getProfileSpy = vi - .fn() - .mockImplementation( - () => - new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( - (resolve) => (resolveProfile = resolve), - ), - ) - // @ts-ignore - The spy-backed double only needs the stalled getProfile member. - provider.providerSettingsManager = { getProfile: getProfileSpy } + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) await provider.saveViewState("currentApiConfigName", "cfg-a") const load = provider["setViewStateId"]("stable-sidebar-view") @@ -1716,22 +1723,7 @@ describe("ClineProvider", () => { it("should reapply an independently mutated profile name when the load settles", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - let resolveProfile: (value: { - name: string - apiProvider: string - openRouterModelId: string - }) => void = () => {} - // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. - const getProfileSpy = vi - .fn() - .mockImplementation( - () => - new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( - (resolve) => (resolveProfile = resolve), - ), - ) - // @ts-ignore - The spy-backed double only needs the stalled getProfile member. - provider.providerSettingsManager = { getProfile: getProfileSpy } + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) await provider.saveViewState("currentApiConfigName", "cfg-a") const load = provider["setViewStateId"]("stable-sidebar-view") @@ -1757,22 +1749,7 @@ describe("ClineProvider", () => { it("should reapply an independently mutated apiConfiguration when the load settles", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - let resolveProfile: (value: { - name: string - apiProvider: string - openRouterModelId: string - }) => void = () => {} - // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. - const getProfileSpy = vi - .fn() - .mockImplementation( - () => - new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( - (resolve) => (resolveProfile = resolve), - ), - ) - // @ts-ignore - The spy-backed double only needs the stalled getProfile member. - provider.providerSettingsManager = { getProfile: getProfileSpy } + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) await provider.saveViewState("currentApiConfigName", "cfg-a") const load = provider["setViewStateId"]("stable-sidebar-view") @@ -1798,22 +1775,7 @@ describe("ClineProvider", () => { it("should keep every persisted field authoritative when the load is untouched", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - let resolveProfile: (value: { - name: string - apiProvider: string - openRouterModelId: string - }) => void = () => {} - // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. - const getProfileSpy = vi - .fn() - .mockImplementation( - () => - new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( - (resolve) => (resolveProfile = resolve), - ), - ) - // @ts-ignore - The spy-backed double only needs the stalled getProfile member. - provider.providerSettingsManager = { getProfile: getProfileSpy } + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) // @ts-ignore - Replace customModesManager with a test double (no custom modes). provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } await provider.saveViewState("mode", "code") @@ -1948,13 +1910,11 @@ describe("ClineProvider", () => { activateProfile: vi.fn().mockResolvedValue(profile), listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), - getProfile: vi - .fn() - .mockResolvedValue({ - name: "old-profile", - id: "old-id", - apiProvider: providerIdentifiers.anthropic, - }), + getProfile: vi.fn().mockResolvedValue({ + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.anthropic, + }), } vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) await provider.contextProxy.setValue( diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index b7907f6a1c..d0a2decc59 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1031,7 +1031,7 @@ }, "core/webview/__tests__/ClineProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 198 + "count": 196 } }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { diff --git a/src/package.json b/src/package.json index 7027f516a9..b92852a277 100644 --- a/src/package.json +++ b/src/package.json @@ -289,19 +289,19 @@ "commandPalette": [ { "command": "zoo-code.plusButtonClickedInTab", - "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + "when": "false" }, { "command": "zoo-code.settingsButtonClickedInTab", - "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + "when": "false" }, { "command": "zoo-code.marketplaceButtonClickedInTab", - "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + "when": "false" }, { "command": "zoo-code.historyButtonClickedInTab", - "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + "when": "false" } ] }, From 257385849d532a20455d13d97e3945b8bae641e2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 14 Sep 2026 20:37:05 +0800 Subject: [PATCH 13/34] fix(provider): close mode-switch abort window, drop bracket access, capture view pin on delete Address CodeRabbit walkthrough findings on the F1a unit: - handleModeSwitchUnlocked now bails before the task-level writes when the abort signal has fired, closing the partial-apply window where a cancelled switch could still rewrite the persisted task mode; the existing pre-write guard still covers in-flight aborts. - Replace bracket access to sibling-instance private members with a typed pinnedProfileName getter and direct private member access (compile-time safe across instances). - deleteProviderProfile now captures this view's pin before the currentApiConfigName rewrite so a view pinned to the deleted profile while the global selection points elsewhere is still reconfigured with the surviving profile's settings. Tests: focusInput asserts the tab panel by identity and that no error was logged on the success path; the stalled getProfile double fails loudly on a second lookup (only one lookup is resolvable). --- .../__tests__/registerCommands.spec.ts | 6 ++- src/core/webview/ClineProvider.ts | 44 +++++++++++++++---- .../webview/__tests__/ClineProvider.spec.ts | 12 ++++- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 043059c260..cc29837676 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -408,7 +408,11 @@ describe("registerCommands handlers", () => { await handlers["zoo-code.focusInput"]() - expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) + // The tab takes selection priority: assert it was selected by identity + // (reference, not structural equality). + expect((ClineProvider.getInstanceForView as Mock).mock.calls[0]![0]).toBe(tabPanel) + // No error was logged on the success path. + expect(mockOutputChannel.appendLine).not.toHaveBeenCalled() expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5f5c4b9fb8..e71f9725c6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -353,6 +353,15 @@ export class ClineProvider */ private viewLocalState: Partial = {} + /** + * This view's pinned profile name from the view-local buffer. Exposed for + * sibling-instance inspection (getAllInstances() filtering): `viewLocalState` + * is private and must not be reached through bracket access. + */ + get pinnedProfileName(): string | undefined { + return this.viewLocalState.currentApiConfigName + } + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "oct-2026-v3.86.0-models-aborts-tool-streaming" // v3.86.0 models, aborts, and tool/UI streaming fixes @@ -2058,6 +2067,14 @@ export class ClineProvider ): Promise { const task = targetTask + // A cancelled or timed-out switch must not be partially applied: bail out + // before the task history / _taskMode writes as well as the durable mode + // write below. The pre-write check further down still covers aborts that + // land while the task writes are in flight. + if (signal?.aborted) { + return + } + if (task) { TelemetryService.instance.captureModeSwitch(task.taskId, newMode) task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) @@ -2343,12 +2360,21 @@ export class ClineProvider ) } + // Capture this view's pin before setValue rewrites it: a view pinned to the + // deleted profile while the global selection points elsewhere must still be + // reconfigured, or getState() would keep the deleted profile's settings under + // the surviving profile's name. + const viewWasPinnedToDeleted = this.viewLocalState.currentApiConfigName === profileToDelete.name + await this.setValue("currentApiConfigName", profileToActivate) - if (profileToDelete.name === globalSettings.currentApiConfigName && survivingSettings) { - // The deleted profile was the active one, so the shared provider keys - // and this view's buffer still carry its settings; replace both so - // getState() reports the surviving profile's configuration. + if ( + (profileToDelete.name === globalSettings.currentApiConfigName || viewWasPinnedToDeleted) && + survivingSettings + ) { + // The deleted profile was the active one (globally, or for this view), so + // the shared provider keys and this view's buffer still carry its settings; + // replace both so getState() reports the surviving profile's configuration. await this.contextProxy.setProviderSettings(survivingSettings) await this._saveViewLocalStateFromMutation(survivingSettings) } @@ -2480,7 +2506,7 @@ export class ClineProvider providerSettings: ProviderSettings, ): Promise { const affected = ClineProvider.getAllInstances().filter( - (instance) => instance !== this && instance["viewLocalState"].currentApiConfigName === name, + (instance) => instance !== this && instance.pinnedProfileName === name, ) if (affected.length === 0) { @@ -2489,7 +2515,8 @@ export class ClineProvider await Promise.all( affected.map(async (instance) => { - await instance["_saveViewLocalStateFromMutation"]({ apiConfiguration: providerSettings }) + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) await instance.postStateToWebview() }), ) @@ -2508,7 +2535,7 @@ export class ClineProvider replacementSettings: ProviderSettings | undefined, ): Promise { const affected = ClineProvider.getAllInstances().filter( - (instance) => instance !== this && instance["viewLocalState"].currentApiConfigName === deletedProfileName, + (instance) => instance !== this && instance.pinnedProfileName === deletedProfileName, ) if (affected.length === 0) { @@ -2525,7 +2552,8 @@ export class ClineProvider values.apiConfiguration = replacementSettings } - await instance["_saveViewLocalStateFromMutation"](values) + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation(values) await instance.postStateToWebview() }), ) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index e0c5542c02..ac730c3f6a 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -438,7 +438,17 @@ type StalledProfile = { */ function stallProviderSettingsProfile(provider: ClineProvider) { let resolveProfile: (value: StalledProfile) => void = () => {} - const getProfileSpy = vi.fn(() => new Promise((resolve) => (resolveProfile = resolve))) + const getProfileSpy = vi.fn(() => { + // Only one lookup is resolvable: resolveProfile is bound to the first + // promise's resolver, so a second getProfile would strand its promise. + // Fail loudly instead of hanging. + if (getProfileSpy.mock.calls.length > 1) { + throw new Error( + "stallProviderSettingsProfile: getProfile called more than once; only one lookup is resolvable", + ) + } + return new Promise((resolve) => (resolveProfile = resolve)) + }) // @ts-ignore - Reassign the readonly providerSettingsManager for the test; the double only backs getProfile. provider.providerSettingsManager = { getProfile: getProfileSpy } // Return a stable wrapper around the closure binding: resolveProfile is From 8d13c644c4d7fb40b7d2b94fe135a3b9829e2789 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 14 Sep 2026 21:52:25 +0800 Subject: [PATCH 14/34] test(provider): cover abort and pinned profile deletion guards --- .../webview/__tests__/ClineProvider.spec.ts | 90 +++++++++++++++++++ .../ClineProvider.sticky-mode.spec.ts | 53 +++++++++++ 2 files changed, 143 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index ac730c3f6a..ad2feab680 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1988,6 +1988,96 @@ describe("ClineProvider", () => { expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("keeper-profile") await provider.dispose() }) + + it("reconfigures a view pinned to the deleted profile even when the global selection points elsewhere", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + // The global selection points at the keeper profile, but this view's buffer + // is still pinned to the profile being deleted (it loaded it earlier). + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + provider["viewLocalState"].currentApiConfigName = "old-profile" + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + } + const setProviderSettingsSpy = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // The captured pin must still trigger the reconfiguration: the shared + // provider keys and the view-local buffer both take the surviving + // profile's settings. + expect(setProviderSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ apiProvider: providerIdentifiers.anthropic }), + ) + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual( + expect.objectContaining({ apiProvider: providerIdentifiers.anthropic }), + ) + await provider.dispose() + }) + + it("leaves the view buffer untouched when the deleted profile is neither globally active nor view-pinned", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const otherProfile: ProviderSettingsEntry = { + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile, otherProfile]) + // The global selection and this view's pin both name surviving profiles: + // the deletion must not reconfigure this view's settings. + await provider.contextProxy.setValue("currentApiConfigName", "other-profile") + provider["viewLocalState"].currentApiConfigName = "keeper-profile" + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + }), + } + const setProviderSettingsSpy = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // No reconfiguration: the guard must stay false when neither the global + // selection nor the view pin names the deleted profile. + expect(setProviderSettingsSpy).not.toHaveBeenCalled() + expect(provider.getValues().currentApiConfigName).toBe("other-profile") + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + await provider.dispose() + }) }) describe("postStateToWebviewThrottled", () => { diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 32edd75063..2aa29af67b 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -458,6 +458,59 @@ describe("ClineProvider - Sticky Mode", () => { }), ) }) + + it("bails out before any task write when the mutation signal is already aborted", async () => { + // A minimal typed double keeps the test focused on the mode-switch contract. + const mockTask = { + taskId: "test-task-id", + taskMode: "code", + _taskMode: undefined as string | undefined, + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as unknown as Task + + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { + return Promise.resolve([]) + }) + await provider.addClineToStack(mockTask) + + const abortedController = new AbortController() + abortedController.abort() + await provider["handleModeSwitchUnlocked"]("architect", mockTask, abortedController.signal) + + expect(mockTask.emit).not.toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") + expect(updateTaskHistorySpy).not.toHaveBeenCalled() + expect(mockTask["_taskMode"]).toBeUndefined() + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") + }) + + it("proceeds normally when no mutation signal is provided", async () => { + // A minimal typed double keeps the test focused on the mode-switch contract. + const mockTask = { + taskId: "test-task-id", + taskMode: "code", + _taskMode: undefined as string | undefined, + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as unknown as Task + + vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { + return Promise.resolve([]) + }) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask, undefined) + + expect(mockTask.emit).toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") + expect(mockTask["_taskMode"]).toBe("architect") + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + }) }) describe("createTaskWithHistoryItem", () => { From 2ba6240af185e09e1fd42bb9f9353a777699a607 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 14 Sep 2026 22:08:57 +0800 Subject: [PATCH 15/34] test(provider): assert profile settings write identity --- src/core/webview/__tests__/ClineProvider.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index ad2feab680..bcf08c1124 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2015,9 +2015,7 @@ describe("ClineProvider", () => { apiProvider: providerIdentifiers.anthropic, }), } - const setProviderSettingsSpy = vi - .spyOn(provider.contextProxy, "setProviderSettings") - .mockResolvedValue(undefined) + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") await provider.deleteProviderProfile(oldProfile) From 56d797cac6ecc8d6343793c00c812cbdeb6c98a7 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 15 Sep 2026 09:24:52 +0800 Subject: [PATCH 16/34] fix(provider): make mode switch cancellation atomic and preserve view-local profile pins handleModeSwitchUnlocked: an abort landing while updateTaskHistory is in flight previously left the new mode persisted in task history and assigned to task._taskMode before the pre-write signal check bailed; the landed write is now rolled back to the pre-switch item and the method returns before the TaskModeSwitched emit and the durable mode write. TaskModeSwitched now only fires for a completed transition. deleteProviderProfile: the unconditional setValue('currentApiConfigName', ...) overwrote a view's pin when an unrelated profile was deleted; the pin is now re-pointed only when it names the deleted profile, and a deleted-was-global deletion updates the shared store only. The nested apiConfiguration overlay is replaced with the surviving profile's settings only for a view pinned to the deleted profile. --- src/core/webview/ClineProvider.ts | 52 +++++++++++---- .../webview/__tests__/ClineProvider.spec.ts | 36 +++++++--- .../ClineProvider.sticky-mode.spec.ts | 65 +++++++++++++++++++ 3 files changed, 132 insertions(+), 21 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e71f9725c6..a999c9f1ee 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2069,15 +2069,15 @@ export class ClineProvider // A cancelled or timed-out switch must not be partially applied: bail out // before the task history / _taskMode writes as well as the durable mode - // write below. The pre-write check further down still covers aborts that - // land while the task writes are in flight. + // write below. Aborts that land while the history write is in flight are + // handled in flight (the landed write is rolled back); the pre-write check + // further down still covers the remaining gap before the durable write. if (signal?.aborted) { return } if (task) { TelemetryService.instance.captureModeSwitch(task.taskId, newMode) - task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) try { // Update the task history with the new mode first. @@ -2085,8 +2085,21 @@ export class ClineProvider if (taskHistoryItem) { await this.updateTaskHistory({ ...taskHistoryItem, mode: newMode }) + + // An abort that lands while the history write is in flight has + // already persisted the new mode: restore the pre-switch item and + // bail before the in-memory task write and the emit, so task + // history, task state, and provider mode cannot diverge. + if (signal?.aborted) { + await this.updateTaskHistory(taskHistoryItem) + return + } + } else if (signal?.aborted) { + return } + task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) + // Only update the task's mode after successful persistence. ;(task as any)._taskMode = newMode } catch (error) { @@ -2360,23 +2373,38 @@ export class ClineProvider ) } - // Capture this view's pin before setValue rewrites it: a view pinned to the + // Capture this view's pin before any rewrite: a view pinned to the // deleted profile while the global selection points elsewhere must still be // reconfigured, or getState() would keep the deleted profile's settings under // the surviving profile's name. const viewWasPinnedToDeleted = this.viewLocalState.currentApiConfigName === profileToDelete.name + const deletedWasGlobal = profileToDelete.name === globalSettings.currentApiConfigName - await this.setValue("currentApiConfigName", profileToActivate) + if (viewWasPinnedToDeleted) { + // This view's pin now dangles: re-point it. setValue also persists the + // survivor to the shared store, which covers the deleted-was-global case + // for every other view as well as this one. + await this.setValue("currentApiConfigName", profileToActivate) + } else if (deletedWasGlobal) { + // The shared selection changed, but this view's own pin still names a + // surviving profile: update the shared store only, leaving the + // view-local pin untouched. + await this.contextProxy.setValue("currentApiConfigName", profileToActivate) + } - if ( - (profileToDelete.name === globalSettings.currentApiConfigName || viewWasPinnedToDeleted) && - survivingSettings - ) { + if ((deletedWasGlobal || viewWasPinnedToDeleted) && survivingSettings) { // The deleted profile was the active one (globally, or for this view), so - // the shared provider keys and this view's buffer still carry its settings; - // replace both so getState() reports the surviving profile's configuration. + // the shared provider keys still carry its settings; replace them so + // getState() reports the surviving profile's configuration. await this.contextProxy.setProviderSettings(survivingSettings) - await this._saveViewLocalStateFromMutation(survivingSettings) + + if (viewWasPinnedToDeleted) { + // This view's nested overlay (viewLocalState.apiConfiguration, seeded + // by loadViewState) still serves the deleted profile's configuration: + // replace it with the survivor's so the re-pointed pin serves matching + // settings. A view pinned to another profile keeps its own overlay. + await this._saveViewLocalStateFromMutation({ apiConfiguration: survivingSettings }) + } } // Re-pin other live views still buffered on the deleted profile: their diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bcf08c1124..1a5b1d0852 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2003,9 +2003,14 @@ describe("ClineProvider", () => { } await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) // The global selection points at the keeper profile, but this view's buffer - // is still pinned to the profile being deleted (it loaded it earlier). + // is still pinned to the profile being deleted (it loaded it earlier): + // the nested overlay still carries the deleted profile's configuration. await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") provider["viewLocalState"].currentApiConfigName = "old-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "deleted-profile-secret", + } vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // @ts-ignore - Replace providerSettingsManager with a test double. provider.providerSettingsManager = { @@ -2021,14 +2026,16 @@ describe("ClineProvider", () => { // The captured pin must still trigger the reconfiguration: the shared // provider keys and the view-local buffer both take the surviving - // profile's settings. + // profile's settings, and the nested overlay is replaced wholesale so + // no key of the deleted profile survives. expect(setProviderSettingsSpy).toHaveBeenCalledWith( expect.objectContaining({ apiProvider: providerIdentifiers.anthropic }), ) expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") - expect(provider["viewLocalState"].apiConfiguration).toEqual( - expect.objectContaining({ apiProvider: providerIdentifiers.anthropic }), - ) + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }) await provider.dispose() }) @@ -2051,9 +2058,14 @@ describe("ClineProvider", () => { } await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile, otherProfile]) // The global selection and this view's pin both name surviving profiles: - // the deletion must not reconfigure this view's settings. + // the deletion must not reconfigure this view's settings, and the + // pinned view's nested overlay must survive byte-for-byte. await provider.contextProxy.setValue("currentApiConfigName", "other-profile") provider["viewLocalState"].currentApiConfigName = "keeper-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-profile-secret", + } vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // @ts-ignore - Replace providerSettingsManager with a test double. provider.providerSettingsManager = { @@ -2070,10 +2082,16 @@ describe("ClineProvider", () => { await provider.deleteProviderProfile(oldProfile) // No reconfiguration: the guard must stay false when neither the global - // selection nor the view pin names the deleted profile. + // selection nor the view pin names the deleted profile, so the view + // keeps its own pin and its nested overlay while the shared store + // reports the global selection. expect(setProviderSettingsSpy).not.toHaveBeenCalled() - expect(provider.getValues().currentApiConfigName).toBe("other-profile") - expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("other-profile") + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-profile-secret", + }) await provider.dispose() }) }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 2aa29af67b..c6ec52ba24 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -487,6 +487,71 @@ describe("ClineProvider - Sticky Mode", () => { expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") }) + it("rolls back the landed history write and leaves no partial mode state when the signal aborts in flight", async () => { + // A minimal typed double keeps the test focused on the mode-switch contract. + const mockTask = { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as unknown as Task + + const historyItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + + // The history write settles only after the abort lands: the first call is + // controlled, the rollback call resolves immediately. + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockResolvedValueOnce([]) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await switchPromise + + // The persisted new mode is rolled back to the pre-switch item. + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ id: "test-task-id", mode: "architect" }), + ) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith(2, historyItem) + // No partial mode state: the task keeps its previous mode, nothing was + // emitted, and the durable provider mode write never happened. + expect(mockTask["_taskMode"]).toBe("code") + expect(mockTask.emit).not.toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") + }) + it("proceeds normally when no mutation signal is provided", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. const mockTask = { From 6c9856db16535733be93fad191957bd459c2d99c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 15 Sep 2026 10:01:46 +0800 Subject: [PATCH 17/34] fix(core/webview): purge deleted provider profile from the settings store deleteProviderProfile pruned the UI-facing listApiConfigMeta entry but never removed the profile's settings from the ProviderSettingsManager store (context.secrets), so a later listApiConfigMeta sync could resurrect the deleted profile and a dangling per-mode mapping could re-activate it. The purge now calls providerSettingsManager.deleteConfig and branches on the typed ProviderSettingsNotFoundError (introduced here alongside) so an already-gone secret is an idempotent success -- the stale list entry is still pruned -- while any other failure (e.g. the store refusing to delete the last remaining configuration) propagates. Matching message text instead would let a profile whose name contains 'not found' swallow an unrelated failure. Tests: the dangling-mode-mapping resurrection scenario, the already-gone secret, the store-level last-profile refusal, the typed-signal contract in the manager spec, and provider-level not-found/propagation pins. --- src/core/config/ProviderSettingsManager.ts | 20 ++- .../__tests__/ProviderSettingsManager.spec.ts | 12 +- src/core/webview/ClineProvider.ts | 21 ++- .../webview/__tests__/ClineProvider.spec.ts | 62 +++++++++ .../ClineProvider.sticky-profile.spec.ts | 122 ++++++++++++++++++ 5 files changed, 234 insertions(+), 3 deletions(-) diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 3fcc0e6e43..5cbd87d1cc 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -52,6 +52,19 @@ export const providerProfilesSchema = z.object({ export type ProviderProfiles = z.infer +/** + * Signals that a profile's configuration no longer exists. Callers that treat an + * already-deleted profile as an idempotent no-op branch on this type instead of + * matching error message text, which a profile name containing the phrase could + * otherwise spoof. + */ +export class ProviderSettingsNotFoundError extends Error { + constructor(message: string) { + super(message) + this.name = "ProviderSettingsNotFoundError" + } +} + export class ProviderSettingsManager { private static readonly SCOPE_PREFIX = "roo_cline_config_" private readonly defaultConfigId = this.generateId() @@ -474,7 +487,7 @@ export class ProviderSettingsManager { const providerProfiles = await this.load() if (!providerProfiles.apiConfigs[name]) { - throw new Error(`Config '${name}' not found`) + throw new ProviderSettingsNotFoundError(`Config '${name}' not found`) } if (Object.keys(providerProfiles.apiConfigs).length === 1) { @@ -485,6 +498,11 @@ export class ProviderSettingsManager { await this.store(providerProfiles) }) } catch (error) { + // A missing config is a caller-meaningful signal, not a failure: rethrow it + // unwrapped so callers can branch on the type instead of message text. + if (error instanceof ProviderSettingsNotFoundError) { + throw error + } throw new Error(`Failed to delete config: ${error}`) } } diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 13e1aeeb2d..5f0a68ece5 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -12,7 +12,12 @@ import { import { clearAllMocks } from "../../../test-utils/reset" import { makeExtensionContext } from "../../../test-utils/vscode" -import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager" +import { + ProviderSettingsManager, + ProviderSettingsNotFoundError, + ProviderProfiles, + SyncCloudProfilesResult, +} from "../ProviderSettingsManager" // `export()` builds an API handler per profile to read model capabilities. Mock // buildApiHandler with the real @roo-code/types model definitions so the token-field @@ -745,6 +750,11 @@ describe("ProviderSettingsManager", () => { }), ) + // The typed not-found signal is the contract callers branch on: a profile + // name containing "not found" must not be matchable via message text. + await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toBeInstanceOf( + ProviderSettingsNotFoundError, + ) await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toThrow( "Config 'nonexistent' not found", ) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a999c9f1ee..aa3dffe2c9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -111,7 +111,7 @@ import { buildApiHandler } from "../../api" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio" import { ContextProxy } from "../config/ContextProxy" -import { ProviderSettingsManager } from "../config/ProviderSettingsManager" +import { ProviderSettingsManager, ProviderSettingsNotFoundError } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" @@ -2348,6 +2348,25 @@ export class ClineProvider throw new Error("You cannot delete the last profile") } + // Remove the profile from the settings store (context.secrets) so it cannot be + // resurrected by a later listApiConfigMeta sync. A not-found rejection means + // the secret was already gone (e.g. pruned by an earlier run): branch on the + // typed ProviderSettingsNotFoundError so the stale list entry below is still + // pruned as an idempotent success, while any other failure (e.g. refusing to + // delete the last remaining configuration) propagates. Matching message text + // instead would let a profile whose name contains "not found" swallow an + // unrelated failure. + try { + await this.providerSettingsManager.deleteConfig(profileToDelete.name) + } catch (error) { + if (!(error instanceof ProviderSettingsNotFoundError)) { + throw error + } + this.log( + `deleteProviderProfile: settings for '${profileToDelete.name}' were not found; pruning the stale list entry only`, + ) + } + const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) // Write only the profile list back: replaying the full settings snapshot diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1a5b1d0852..ecbef4d666 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -29,6 +29,7 @@ import { defaultModeSlug } from "../../../shared/modes" import { experimentDefault } from "../../../shared/experiments" import { setTtsEnabled } from "../../../utils/tts" import { ContextProxy } from "../../config/ContextProxy" +import { ProviderSettingsNotFoundError } from "../../config/ProviderSettingsManager" import { WorkspaceIndexingEnablementManager } from "../../../services/code-index/workspace-indexing-enablement-manager" import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" @@ -1989,6 +1990,65 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("should swallow only the typed not-found signal when pruning a stale profile entry", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const staleProfile: ProviderSettingsEntry = { + name: "stale-profile", + id: "stale-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [staleProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // The secret was already pruned: the typed not-found must be an idempotent + // success so the stale list entry is still removed. + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockRejectedValue( + new ProviderSettingsNotFoundError(`Config 'stale-profile' not found`), + ) + + await provider.deleteProviderProfile(staleProfile) + + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([keeperProfile]) + await provider.dispose() + }) + + it("should propagate a non-not-found deletion failure for a profile named like the not-found message", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const profile: ProviderSettingsEntry = { + name: "not found config", + id: "nf-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [profile, keeperProfile]) + await provider.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // An unrelated failure (wrapped the way deleteConfig wraps storage errors) + // must not be mistaken for the idempotent not-found path just because the + // profile name contains "not found". + const deleteConfigSpy = vi + .spyOn(provider.providerSettingsManager, "deleteConfig") + .mockRejectedValue( + new Error(`Failed to delete config: Error: storage write failed for 'not found config'`), + ) + + await expect(provider.deleteProviderProfile(profile)).rejects.toThrow("storage write failed") + + // The list entry must remain untouched when the deletion failed. + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([profile, keeperProfile]) + expect(deleteConfigSpy).toHaveBeenCalledTimes(1) + await provider.dispose() + }) + it("reconfigures a view pinned to the deleted profile even when the global selection points elsewhere", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) const oldProfile: ProviderSettingsEntry = { @@ -2019,6 +2079,7 @@ describe("ClineProvider", () => { id: "keeper-id", apiProvider: providerIdentifiers.anthropic, }), + deleteConfig: vi.fn().mockResolvedValue(undefined), } const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") @@ -2074,6 +2135,7 @@ describe("ClineProvider", () => { id: "other-id", apiProvider: providerIdentifiers.openrouter, }), + deleteConfig: vi.fn().mockResolvedValue(undefined), } const setProviderSettingsSpy = vi .spyOn(provider.contextProxy, "setProviderSettings") diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index 7d8493fba3..306602ad14 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -1015,4 +1015,126 @@ describe("ClineProvider - Sticky Provider Profile", () => { ) }) }) + + describe("deleteProviderProfile", () => { + it("removes the stored profile so a dangling mode mapping can no longer re-activate it", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Seed the stored profile settings: the default profile plus a child + // profile (mirroring the cross-profile subtasks e2e scenario), with the + // "ask" mode mapped to the child profile. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + const childId = await provider.providerSettingsManager.saveConfig("subtask-child-profile", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1-mini", + }) + await provider.providerSettingsManager.setModeConfig("ask", childId) + + // The UI-facing list mirrors the store, as maintained by upsert/activate. + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "subtask-child-profile", id: childId, apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "subtask-child-profile", + }) + + await provider.deleteProviderProfile({ + name: "subtask-child-profile", + id: childId, + apiProvider: providerIdentifiers.openrouter, + }) + + // The deleted profile's settings are gone from the manager store. + const remaining = await provider.providerSettingsManager.listConfig() + expect(remaining.some((config) => config.name === "subtask-child-profile")).toBe(false) + await expect( + provider.providerSettingsManager.getProfile({ name: "subtask-child-profile" }), + ).rejects.toThrow(/subtask-child-profile.*not found/) + + // The mode mapping still points at the deleted id, but it no longer + // resolves to a stored profile, so handleModeSwitch falls through to the + // current configuration instead of re-activating the deleted profile. + const savedConfigId = await provider.providerSettingsManager.getModeConfigId("ask") + expect(savedConfigId).toBe(childId) + expect(remaining.find(({ id }) => id === savedConfigId)).toBeUndefined() + + // The context was repointed at the surviving profile. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + }) + + it("treats an already-gone secret as success so the stale list entry is still pruned", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Only the default profile exists in the store: the ghost profile's secret + // was already gone (e.g. pruned by an earlier run) but its list entry + // survived. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "ghost-profile", id: "ghost-id", apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "ghost-profile", + }) + + // The manager's "not found" rejection must not surface to the caller ... + await expect( + provider.deleteProviderProfile({ + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.openrouter, + }), + ).resolves.not.toThrow() + + // ... it prunes the stale list entry and repoints the selection. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + }) + + it("still refuses to delete the last stored profile when a stale list entry remains", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + // A ghost list entry survives next to the only real profile, so the + // provider-level "last profile" guard does not fire: the refusal must come + // from the settings store itself. + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "ghost-profile", id: "ghost-id", apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "default", + }) + + await expect( + provider.deleteProviderProfile({ + name: "default", + id: defaultId, + apiProvider: providerIdentifiers.openrouter, + }), + ).rejects.toThrow("Cannot delete the last remaining configuration") + + // Nothing was repointed or pruned. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default", "ghost-profile"]) + }) + }) }) From 67dbd22b2f316065a8d56e238bcf024e7be0a80c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 28 Sep 2026 03:43:30 +0800 Subject: [PATCH 18/34] test: replace double assertions with single structural casts in view-identity specs --- .../__tests__/registerCommands.spec.ts | 64 ++++++++------- .../webview/__tests__/ClineProvider.spec.ts | 47 +++++------ .../ClineProvider.sticky-mode.spec.ts | 78 ++++++++++++------- 3 files changed, 109 insertions(+), 80 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index cc29837676..b86e40a22a 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -643,12 +643,12 @@ describe("openClineInNewTab", () => { it("reveals the existing tab instead of creating a second panel", async () => { const mockExistingProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } - const mockPanel = { + const mockPanel = Object.assign({} as vscode.WebviewPanel, { 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) @@ -664,12 +664,12 @@ describe("openClineInNewTab", () => { }) it("creates a new tab panel when the tracked tab's provider has been disposed", async () => { - const mockPanel = { + const mockPanel = Object.assign({} as vscode.WebviewPanel, { 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) @@ -681,22 +681,22 @@ describe("openClineInNewTab", () => { it("re-points the tracked tab ref at the panel that becomes active", async () => { // Panel A is created first and tracked... - const panelA = { + const panelA = Object.assign({} as vscode.WebviewPanel, { marker: "panel-A", webview: { postMessage: vi.fn() }, onDidChangeViewState: vi.fn(), onDidDispose: vi.fn(), - } as unknown as vscode.WebviewPanel + }) ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelA) await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) // ...then panel B is created, which re-points the tracked tab ref. - const panelB = { + const panelB = Object.assign({} as vscode.WebviewPanel, { marker: "panel-B", webview: { postMessage: vi.fn() }, onDidChangeViewState: vi.fn(), onDidDispose: vi.fn(), - } as unknown as vscode.WebviewPanel + }) ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelB) await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) @@ -722,11 +722,13 @@ describe("openClineInNewTab", () => { return { dispose: vi.fn() } }, ) - const mockSidebarProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const mockSidebarProvider = Object.assign({} as ClineProvider, { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + }) registerCommands({ context: mockContext, outputChannel: mockOutputChannel, - provider: mockSidebarProvider as unknown as ClineProvider, + provider: mockSidebarProvider, }) await handlers.get("zoo-code.plusButtonClickedInTab")!() @@ -746,7 +748,7 @@ describe("openClineInNewTab", () => { // 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 + const ctor = vi.mocked(ClineProvider) expect(ctor.mock.instances[0]).toBeDefined() expect(ctor).toHaveBeenCalledWith(mockContext, mockOutputChannel, "editor", undefined, undefined) expect(provider).toBe(ctor.mock.instances[0]) @@ -759,7 +761,9 @@ describe("openClineInNewTab", () => { }) 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 = [] + // The vscode mock factory declares a mutable visibleTextEditors slot that the + // readonly public API type hides, so seed it through Object.assign. + Object.assign(vscode.window, { visibleTextEditors: [] }) await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) @@ -789,10 +793,10 @@ describe("openClineInNewTab", () => { 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, - ] + const editorWithoutColumn = Object.assign({} as vscode.TextEditor, { viewColumn: undefined }) + Object.assign(vscode.window, { + visibleTextEditors: [editorWithoutColumn], + }) await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) @@ -810,10 +814,12 @@ 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, - ] + Object.assign(vscode.window, { + visibleTextEditors: [ + Object.assign({} as vscode.TextEditor, { viewColumn: 1 }), + Object.assign({} as vscode.TextEditor, { viewColumn: 3 }), + ], + }) await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) @@ -829,13 +835,13 @@ describe("openClineInNewTab", () => { }) 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 mockMdm = Object.assign({} as MdmService, { name: "mock-mdm" }) + ;(MdmService.getInstance as Mock).mockReturnValue(mockMdm) const provider = await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) - const ctor = ClineProvider as unknown as Mock + const ctor = vi.mocked(ClineProvider) expect(ctor).toHaveBeenCalledTimes(1) expect(ctor).toHaveBeenCalledWith(mockContext, mockOutputChannel, "editor", undefined, mockMdm) expect(provider).toBe(ctor.mock.instances[0]) @@ -883,7 +889,7 @@ describe("openClineInNewTab", () => { // 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 ctor = vi.mocked(ClineProvider) const constructed = ctor.mock.instances[0] expect(constructed).toBeDefined() expect(first).toBe(constructed) @@ -908,11 +914,13 @@ describe("openClineInNewTab", () => { return { dispose: vi.fn() } }, ) - const sidebarProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const sidebarProvider = Object.assign({} as ClineProvider, { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + }) registerCommands({ context: mockContext, outputChannel: mockOutputChannel, - provider: sidebarProvider as unknown as ClineProvider, + provider: sidebarProvider, }) const started = [commandHandlers["zoo-code.openInNewTab"](), commandHandlers["zoo-code.popoutButtonClicked"]()] @@ -927,7 +935,7 @@ describe("openClineInNewTab", () => { // 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 ctor = vi.mocked(ClineProvider) const constructed = ctor.mock.instances[0] expect(constructed).toBeDefined() expect(first).toBe(constructed) @@ -947,7 +955,7 @@ describe("openClineInNewTab", () => { const secondProvider = await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) - const ctor = ClineProvider as unknown as Mock + const ctor = vi.mocked(ClineProvider) const second = ctor.mock.instances[1] expect(second).toBeDefined() expect(secondProvider).toBe(second) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index ecbef4d666..008f733bad 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -982,12 +982,12 @@ describe("ClineProvider", () => { test("postStateToWebview does not force action navigation for non-compliant MDM state", async () => { // Structural double: the post path only reads these two members, and - // MdmService cannot be constructed as a plain object, so a double - // assertion is the last-resort cast here. - const mdmService = { + // MdmService cannot be constructed as a plain object; Object.assign + // keeps this a single structural assertion. + const mdmService = Object.assign({} as MdmService, { requiresCloudAuth: vi.fn().mockReturnValue(true), isCompliant: vi.fn().mockReturnValue({ compliant: false, reason: "auth required" }), - } as unknown as MdmService + }) provider = new ClineProvider( mockContext, @@ -998,9 +998,11 @@ describe("ClineProvider", () => { ) const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockImplementation(async () => undefined) - vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue({ - version: "1.0.0", - } as unknown as ExtensionState) + vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue( + Object.assign({} as ExtensionState, { + version: "1.0.0", + }), + ) await provider.postStateToWebview() @@ -1015,9 +1017,11 @@ describe("ClineProvider", () => { }) let statePostSettled = false - vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue({ - taskHistory: [], - } as unknown as ExtensionState) + vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue( + Object.assign({} as ExtensionState, { + taskHistory: [], + }), + ) const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockReturnValue(pendingPost) const statePost = provider.postStateToWebviewWithoutTaskHistory() @@ -1254,17 +1258,12 @@ describe("ClineProvider", () => { 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 provider["setViewStateId"]("stable-sidebar-view") - await expect(providerAccess.saveViewState("mode", "architect")).rejects.toThrow("persist failed") - expect(providerAccess.viewLocalState).not.toHaveProperty("mode") + await expect(provider.saveViewState("mode", "architect")).rejects.toThrow("persist failed") + expect(provider["viewLocalState"]).not.toHaveProperty("mode") expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() await provider.dispose() @@ -1826,9 +1825,10 @@ describe("ClineProvider", () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "bogus-mode"')) expect(mockContext.globalState.get("mode")).toBe("refactor") expect(provider["viewLocalState"].mode).toBe("refactor") - // A non-string mode must be rejected before persistence (double assertion: the type - // excludes non-strings), so it must not reach global state or the buffer. - await provider.setValues({ mode: 42 } as unknown as RooCodeSettings) + // A non-string mode must be rejected before persistence; Object.assign + // keeps this a single structural assertion, so it must not reach global + // state or the buffer. + await provider.setValues(Object.assign({} as RooCodeSettings, { mode: 42 })) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "42"')) expect(mockContext.globalState.get("mode")).toBe("refactor") expect(provider["viewLocalState"].mode).toBe("refactor") @@ -1890,7 +1890,10 @@ describe("ClineProvider", () => { 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. + // The modal answer is a string label; the last-typed vscode overload expects a + // MessageItem. A double assertion is the last-resort cast here (AGENTS.md): the + // production path compares the answer against the string label directly, so the + // runtime value must stay a string and cannot be a structural MessageItem double. vi.mocked(vscode.window.showInformationMessage).mockResolvedValue( t("common:answers.yes") as unknown as vscode.MessageItem, ) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index c6ec52ba24..afc6f38812 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -461,16 +461,22 @@ describe("ClineProvider - Sticky Mode", () => { it("bails out before any task write when the mutation signal is already aborted", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. - const mockTask = { - taskId: "test-task-id", - taskMode: "code", - _taskMode: undefined as string | undefined, - emit: vi.fn(), - saveClineMessages: vi.fn(), - clineMessages: [], - apiConversationHistory: [], - updateApiConfiguration: vi.fn(), - } as unknown as Task + // The literal is asserted as Partial so its private members (_taskMode, + // saveClineMessages) stay out of the Object.assign intersection type, which + // would otherwise collapse to never. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: undefined as string | undefined, + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { return Promise.resolve([]) @@ -489,16 +495,22 @@ describe("ClineProvider - Sticky Mode", () => { it("rolls back the landed history write and leaves no partial mode state when the signal aborts in flight", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. - const mockTask = { - taskId: "test-task-id", - taskMode: "code", - _taskMode: "code", - emit: vi.fn(), - saveClineMessages: vi.fn(), - clineMessages: [], - apiConversationHistory: [], - updateApiConfiguration: vi.fn(), - } as unknown as Task + // The literal is asserted as Partial so its private members (_taskMode, + // saveClineMessages) stay out of the Object.assign intersection type, which + // would otherwise collapse to never. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) const historyItem: HistoryItem = { id: "test-task-id", @@ -554,16 +566,22 @@ describe("ClineProvider - Sticky Mode", () => { it("proceeds normally when no mutation signal is provided", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. - const mockTask = { - taskId: "test-task-id", - taskMode: "code", - _taskMode: undefined as string | undefined, - emit: vi.fn(), - saveClineMessages: vi.fn(), - clineMessages: [], - apiConversationHistory: [], - updateApiConfiguration: vi.fn(), - } as unknown as Task + // The literal is asserted as Partial so its private members (_taskMode, + // saveClineMessages) stay out of the Object.assign intersection type, which + // would otherwise collapse to never. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: undefined as string | undefined, + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { return Promise.resolve([]) From 1004cbdb6866d7a178010c0aa45063dfc0ea32ad Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 28 Sep 2026 05:04:47 +0800 Subject: [PATCH 19/34] fix(webview): contain the abort rollback failure in handleModeSwitch A failed task-history rollback during an aborted mode switch previously propagated into the outer persistence-error handler and surfaced as the switch's own persistence failure. Guard the rollback with its own try/catch so the rollback error is logged with task context and the cancellation return is preserved (CodeRabbit finding on this PR). --- src/core/webview/ClineProvider.ts | 10 +++- .../ClineProvider.sticky-mode.spec.ts | 60 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index aa3dffe2c9..0c9c55f63c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2089,9 +2089,15 @@ export class ClineProvider // An abort that lands while the history write is in flight has // already persisted the new mode: restore the pre-switch item and // bail before the in-memory task write and the emit, so task - // history, task state, and provider mode cannot diverge. + // history, task state, and provider mode cannot diverge. A failed + // rollback must not surface as the persistence error of the + // cancelled switch: log it and keep the cancellation result. if (signal?.aborted) { - await this.updateTaskHistory(taskHistoryItem) + try { + await this.updateTaskHistory(taskHistoryItem) + } catch (rollbackError) { + this.log(`Failed to roll back mode switch ${task.taskId}: ${String(rollbackError)}`) + } return } } else if (signal?.aborted) { diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index afc6f38812..67939682a8 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -564,6 +564,66 @@ describe("ClineProvider - Sticky Mode", () => { expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") }) + it("keeps the cancellation result when the abort rollback write itself fails", async () => { + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const historyItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + + // The first write settles after the abort lands; the rollback write rejects: + // the failure is logged with its own message and the cancelled switch still + // resolves (it must not surface as the switch's persistence error). + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockRejectedValueOnce(new Error("rollback write failed")) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await expect(switchPromise).resolves.toBeUndefined() + + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("Failed to roll back mode switch"), + ) + }) + it("proceeds normally when no mutation signal is provided", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. // The literal is asserted as Partial so its private members (_taskMode, From dfb826420e3786417bb572759143736dc21f3dd2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 28 Sep 2026 05:16:33 +0800 Subject: [PATCH 20/34] fix(webview): restore only the mode field when rolling back an aborted switch The in-flight abort rollback rewrote the whole task-history item with the pre-switch snapshot, clobbering any fields the running task persisted during the pending window (tokens, cost, status, apiConfigName). Re-read the item and restore only the mode this switch changed (CodeRabbit data-integrity finding on this PR). --- src/core/webview/ClineProvider.ts | 6 +- .../ClineProvider.sticky-mode.spec.ts | 72 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0c9c55f63c..b2097cd502 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2094,7 +2094,11 @@ export class ClineProvider // cancelled switch: log it and keep the cancellation result. if (signal?.aborted) { try { - await this.updateTaskHistory(taskHistoryItem) + // Restore only the field this switch changed: re-read the item so + // fields the running task persisted during the pending window + // (tokens, cost, status, apiConfigName) survive the rollback. + const latest = this.getTaskHistoryItem(task.taskId) ?? taskHistoryItem + await this.updateTaskHistory({ ...latest, mode: taskHistoryItem.mode }) } catch (rollbackError) { this.log(`Failed to roll back mode switch ${task.taskId}: ${String(rollbackError)}`) } diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 67939682a8..01d33c035c 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -624,6 +624,78 @@ describe("ClineProvider - Sticky Mode", () => { ) }) + it("preserves fields persisted during the pending window when rolling back the mode", async () => { + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const preSwitchItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + // While the history write is in flight the running task persists other fields + // of the same item; the store now returns that updated item. + const updatedItem: HistoryItem = { + ...preSwitchItem, + mode: "architect", + tokensIn: 42, + totalCost: 0.5, + } + const storeGet = vi + .spyOn(provider.taskHistoryStore, "get") + .mockReturnValueOnce(preSwitchItem) + .mockReturnValue(updatedItem) + + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockResolvedValueOnce([]) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await expect(switchPromise).resolves.toBeUndefined() + + // The rollback restores only the mode: the fields persisted during the + // pending window (tokensIn, totalCost) survive the rollback write. + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(storeGet).toHaveBeenCalledTimes(2) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ id: "test-task-id", mode: "code", tokensIn: 42, totalCost: 0.5 }), + ) + }) + it("proceeds normally when no mutation signal is provided", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. // The literal is asserted as Partial so its private members (_taskMode, From ce8b27638ebb159f2e6f51e28427b8546b41a4ca Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 21:40:17 +0800 Subject: [PATCH 21/34] fix(activate): drop the CodeIndexManager lookup left by the rebase Rebasing onto main tip merged main's createTabPanelUnlocked body with the PR's serialized creation. Main no longer resolves CodeIndexManager in this function, so the leftover line referenced an import the PR never added and the suite failed with ReferenceError. Removing it restores the PR's own delta: 48 registerCommands tests and 459 F1a tests pass. --- src/activate/registerCommands.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 64b29d5978..f2ed9b53da 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -338,7 +338,6 @@ const createTabPanelUnlocked = async ({ context, outputChannel }: Omit Date: Mon, 7 Sep 2026 03:11:34 +0800 Subject: [PATCH 22/34] fix(core/webview): merge view-local state into getState for per-view overrides Fold ClineProvider viewLocalState on top of ContextProxy values in getState() (mode, apiConfiguration, and all per-view fields) so each webview reports its own selections while falling back to shared global state for everything else. Ports the getState-merging and local-state-isolation spec coverage from the superseded vps2 source. Also pins the full default surface of the merged read path, including the apiConfiguration provider fill-in when provider settings sanitize the raw value away (mutation-diff gate). --- src/core/webview/ClineProvider.ts | 201 +++---- .../webview/__tests__/ClineProvider.spec.ts | 518 ++++++++++++++++++ 2 files changed, 622 insertions(+), 97 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b2097cd502..923f965dee 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3493,12 +3493,18 @@ export class ClineProvider > > { const stateValues = this.contextProxy.getValues() + + // Merge viewLocalState on top of global state so a provider can serve + // state values scoped to its own view while preserving ContextProxy defaults. + const mergedStateValues = { ...stateValues, ...this.viewLocalState } + const customModes = await this.customModesManager.getCustomModes() // Determine apiProvider with the same logic as before, while filtering retired providers. + // Use mergedStateValues to prioritize viewLocalState for parallel mode support const apiProvider: ProviderName = - stateValues.apiProvider && !isRetiredProvider(stateValues.apiProvider) - ? stateValues.apiProvider + mergedStateValues.apiProvider && !isRetiredProvider(mergedStateValues.apiProvider) + ? mergedStateValues.apiProvider : providerIdentifiers.anthropic // Build the apiConfiguration object combining state values and secrets. @@ -3560,121 +3566,122 @@ export class ClineProvider // Return the same structure as before. return { - apiConfiguration: providerSettings, - lastShownAnnouncementId: stateValues.lastShownAnnouncementId, - customInstructions: stateValues.customInstructions, - apiModelId: stateValues.apiModelId, - alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, - alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, - allowedReadFiles: stateValues.allowedReadFiles ?? [], - alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, - alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, - alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, - allowedWriteFiles: stateValues.allowedWriteFiles ?? [], - alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, + apiConfiguration: { + ...providerSettings, + ...mergedStateValues.apiConfiguration, + }, + lastShownAnnouncementId: mergedStateValues.lastShownAnnouncementId, + customInstructions: mergedStateValues.customInstructions, + apiModelId: mergedStateValues.apiModelId, + alwaysAllowReadOnly: mergedStateValues.alwaysAllowReadOnly ?? false, + alwaysAllowReadOnlyOutsideWorkspace: mergedStateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, + allowedReadFiles: mergedStateValues.allowedReadFiles ?? [], + alwaysAllowWrite: mergedStateValues.alwaysAllowWrite ?? false, + alwaysAllowWriteOutsideWorkspace: mergedStateValues.alwaysAllowWriteOutsideWorkspace ?? false, + alwaysAllowWriteProtected: mergedStateValues.alwaysAllowWriteProtected ?? false, + allowedWriteFiles: mergedStateValues.allowedWriteFiles ?? [], + alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: - stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, - alwaysDenyUnapprovedCommands: - stateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, - alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, - alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, - alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, - alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false, - followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000, - diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true, - allowedMaxRequests: stateValues.allowedMaxRequests, - allowedMaxCost: stateValues.allowedMaxCost, - autoCondenseContext: stateValues.autoCondenseContext ?? true, - autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, + mergedStateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + alwaysAllowMcp: mergedStateValues.alwaysAllowMcp ?? false, + alwaysAllowModeSwitch: mergedStateValues.alwaysAllowModeSwitch ?? false, + alwaysAllowSubtasks: mergedStateValues.alwaysAllowSubtasks ?? false, + alwaysAllowFollowupQuestions: mergedStateValues.alwaysAllowFollowupQuestions ?? false, + followupAutoApproveTimeoutMs: mergedStateValues.followupAutoApproveTimeoutMs ?? 60000, + diagnosticsEnabled: mergedStateValues.diagnosticsEnabled ?? true, + allowedMaxRequests: mergedStateValues.allowedMaxRequests, + allowedMaxCost: mergedStateValues.allowedMaxCost, + autoCondenseContext: mergedStateValues.autoCondenseContext ?? true, + autoCondenseContextPercent: mergedStateValues.autoCondenseContextPercent ?? 100, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll() : [], - allowedCommands: stateValues.allowedCommands, - deniedCommands: stateValues.deniedCommands, - soundEnabled: stateValues.soundEnabled ?? false, - ttsEnabled: stateValues.ttsEnabled ?? false, - ttsSpeed: stateValues.ttsSpeed ?? 1.0, - enableCheckpoints: stateValues.enableCheckpoints ?? true, - checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - soundVolume: stateValues.soundVolume, - writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, - diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, + allowedCommands: mergedStateValues.allowedCommands, + deniedCommands: mergedStateValues.deniedCommands, + soundEnabled: mergedStateValues.soundEnabled ?? false, + ttsEnabled: mergedStateValues.ttsEnabled ?? false, + ttsSpeed: mergedStateValues.ttsSpeed ?? 1.0, + enableCheckpoints: mergedStateValues.enableCheckpoints ?? true, + checkpointTimeout: mergedStateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + soundVolume: mergedStateValues.soundVolume, + writeDelayMs: mergedStateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, + diffFuzzyThreshold: mergedStateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, terminalShellIntegrationTimeout: - stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, - terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true, - terminalCommandDelay: stateValues.terminalCommandDelay ?? 0, - terminalPowershellCounter: stateValues.terminalPowershellCounter ?? false, - terminalZshClearEolMark: stateValues.terminalZshClearEolMark ?? true, - terminalZshOhMy: stateValues.terminalZshOhMy ?? false, - terminalZshP10k: stateValues.terminalZshP10k ?? false, - terminalZdotdir: stateValues.terminalZdotdir ?? false, - terminalProfile: stateValues.terminalProfile, - mode: stateValues.mode ?? defaultModeSlug, - language: stateValues.language ?? formatLanguage(vscode.env.language), - mcpEnabled: stateValues.mcpEnabled ?? true, + mergedStateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, + terminalShellIntegrationDisabled: mergedStateValues.terminalShellIntegrationDisabled ?? true, + terminalCommandDelay: mergedStateValues.terminalCommandDelay ?? 0, + terminalPowershellCounter: mergedStateValues.terminalPowershellCounter ?? false, + terminalZshClearEolMark: mergedStateValues.terminalZshClearEolMark ?? true, + terminalZshOhMy: mergedStateValues.terminalZshOhMy ?? false, + terminalZshP10k: mergedStateValues.terminalZshP10k ?? false, + terminalZdotdir: mergedStateValues.terminalZdotdir ?? false, + terminalProfile: mergedStateValues.terminalProfile, + mode: (mergedStateValues.mode as Mode) ?? defaultModeSlug, + language: mergedStateValues.language ?? formatLanguage(vscode.env.language), + mcpEnabled: mergedStateValues.mcpEnabled ?? true, mcpServers: this.mcpHub?.getAllServers() ?? [], - currentApiConfigName: stateValues.currentApiConfigName ?? "default", - listApiConfigMeta: stateValues.listApiConfigMeta ?? [], - pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {}, - modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), - customModePrompts: stateValues.customModePrompts ?? {}, - customSupportPrompts: stateValues.customSupportPrompts ?? {}, - enhancementApiConfigId: stateValues.enhancementApiConfigId, - experiments: stateValues.experiments ?? experimentDefault, - autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, + currentApiConfigName: mergedStateValues.currentApiConfigName ?? "default", + listApiConfigMeta: mergedStateValues.listApiConfigMeta ?? [], + pinnedApiConfigs: mergedStateValues.pinnedApiConfigs ?? {}, + modeApiConfigs: (mergedStateValues.modeApiConfigs as Record) ?? ({} as Record), + customModePrompts: mergedStateValues.customModePrompts ?? {}, + customSupportPrompts: mergedStateValues.customSupportPrompts ?? {}, + enhancementApiConfigId: mergedStateValues.enhancementApiConfigId, + experiments: mergedStateValues.experiments ?? experimentDefault, + autoApprovalEnabled: mergedStateValues.autoApprovalEnabled ?? false, customModes, - maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, - maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - disabledTools: stateValues.disabledTools, - telemetrySetting: stateValues.telemetrySetting || "unset", - showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, - enableSubfolderRules: stateValues.enableSubfolderRules ?? false, - maxImageFileSize: stateValues.maxImageFileSize ?? 5, - maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, - historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, - reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, - chatFontSize: stateValues.chatFontSize, - enterBehavior: stateValues.enterBehavior ?? "send", + maxOpenTabsContext: mergedStateValues.maxOpenTabsContext ?? 20, + maxWorkspaceFiles: mergedStateValues.maxWorkspaceFiles ?? 200, + disabledTools: mergedStateValues.disabledTools, + telemetrySetting: mergedStateValues.telemetrySetting || "unset", + showRooIgnoredFiles: mergedStateValues.showRooIgnoredFiles ?? false, + enableSubfolderRules: mergedStateValues.enableSubfolderRules ?? false, + maxImageFileSize: mergedStateValues.maxImageFileSize ?? 5, + maxTotalImageSize: mergedStateValues.maxTotalImageSize ?? 20, + historyPreviewCollapsed: mergedStateValues.historyPreviewCollapsed ?? false, + reasoningBlockCollapsed: mergedStateValues.reasoningBlockCollapsed ?? true, + chatFontSize: mergedStateValues.chatFontSize, + enterBehavior: mergedStateValues.enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, organizationAllowList, organizationSettingsVersion, - customCondensingPrompt: stateValues.customCondensingPrompt, - codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, + customCondensingPrompt: mergedStateValues.customCondensingPrompt, + codebaseIndexModels: mergedStateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, codebaseIndexConfig: { - codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, + codebaseIndexEnabled: mergedStateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, codebaseIndexQdrantUrl: - stateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", + mergedStateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", codebaseIndexEmbedderProvider: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? providerIdentifiers.openai, - codebaseIndexEmbedderBaseUrl: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", - codebaseIndexEmbedderModelId: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? providerIdentifiers.openai, + codebaseIndexEmbedderBaseUrl: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", + codebaseIndexEmbedderModelId: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", codebaseIndexEmbedderModelDimension: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, codebaseIndexOpenAiCompatibleBaseUrl: - stateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, - codebaseIndexSearchMaxResults: stateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, - codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, - codebaseIndexBedrockRegion: stateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, - codebaseIndexBedrockProfile: stateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, + codebaseIndexSearchMaxResults: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, + codebaseIndexSearchMinScore: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, + codebaseIndexBedrockRegion: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, + codebaseIndexBedrockProfile: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, codebaseIndexOpenRouterSpecificProvider: - stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, - profileThresholds: stateValues.profileThresholds ?? {}, + profileThresholds: mergedStateValues.profileThresholds ?? {}, lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), - includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, - maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, - includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, - includeCurrentTime: stateValues.includeCurrentTime ?? true, - includeCurrentCost: stateValues.includeCurrentCost ?? true, - maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0, + includeDiagnosticMessages: mergedStateValues.includeDiagnosticMessages ?? true, + maxDiagnosticMessages: mergedStateValues.maxDiagnosticMessages ?? 50, + includeTaskHistoryInEnhance: mergedStateValues.includeTaskHistoryInEnhance ?? true, + includeCurrentTime: mergedStateValues.includeCurrentTime ?? true, + includeCurrentCost: mergedStateValues.includeCurrentCost ?? true, + maxGitStatusFiles: mergedStateValues.maxGitStatusFiles ?? 0, taskSyncEnabled, - imageGenerationProvider: stateValues.imageGenerationProvider, - openRouterImageApiKey: stateValues.openRouterImageApiKey, - openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, - autoCloseZooOpenedFiles: stateValues.autoCloseZooOpenedFiles, - autoCloseZooOpenedFilesAfterUserEdited: stateValues.autoCloseZooOpenedFilesAfterUserEdited, - autoCloseZooOpenedNewFiles: stateValues.autoCloseZooOpenedNewFiles, + imageGenerationProvider: mergedStateValues.imageGenerationProvider, + openRouterImageApiKey: mergedStateValues.openRouterImageApiKey, + openRouterImageGenerationSelectedModel: mergedStateValues.openRouterImageGenerationSelectedModel, + autoCloseZooOpenedFiles: mergedStateValues.autoCloseZooOpenedFiles, + autoCloseZooOpenedFilesAfterUserEdited: mergedStateValues.autoCloseZooOpenedFilesAfterUserEdited, + autoCloseZooOpenedNewFiles: mergedStateValues.autoCloseZooOpenedNewFiles, } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 008f733bad..439340613b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -27,6 +27,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { defaultModeSlug } from "../../../shared/modes" import { experimentDefault } from "../../../shared/experiments" +import { EMBEDDING_MODEL_PROFILES } from "../../../shared/embeddingModels" import { setTtsEnabled } from "../../../utils/tts" import { ContextProxy } from "../../config/ContextProxy" import { ProviderSettingsNotFoundError } from "../../config/ProviderSettingsManager" @@ -2161,6 +2162,523 @@ describe("ClineProvider", () => { }) }) + describe("local state isolation", () => { + it("should isolate mode state between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await provider2.saveViewState("mode", "debugger") + await provider1.saveViewState("mode", "architect") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should isolate currentApiConfigName between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + const saveViewState1 = provider1.saveViewState.bind(provider1) + const saveViewState2 = provider2.saveViewState.bind(provider2) + + await saveViewState1("currentApiConfigName", "profile-a") + await saveViewState2("currentApiConfigName", "profile-b") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.currentApiConfigName).toBe("profile-a") + expect(state2.currentApiConfigName).toBe("profile-b") + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("getState merging", () => { + it("should merge viewLocalState on top of global state", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Initially, getState should return values from contextProxy (global state) + let state = await provider.getState() + expect(state.mode).toBe("code") + + // After saveViewState, viewLocalState should take precedence + await provider.saveViewState("mode", "architect") + + state = await provider.getState() + expect(state.mode).toBe("architect") + + await provider.dispose() + }) + + it("should preserve global state values not overridden by viewLocalState", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.saveViewState("mode", "architect") + + const state = await provider.getState() + + // mode should come from viewLocalState + expect(state.mode).toBe("architect") + + // Other values should still come from global state / contextProxy + expect(state.language).toBeDefined() + expect(state.customModes).toBeDefined() + + await provider.dispose() + }) + + it("should let viewLocalState apiConfiguration override provider settings", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "local-key", + }) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("openrouter") + expect(state.apiConfiguration.openRouterApiKey).toBe("local-key") + + await provider.dispose() + }) + + it("should merge getValues from ContextProxy with view-local values taking precedence", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerAccess = provider as unknown as { + saveViewState: (key: keyof ExtensionState, value: unknown) => Promise + } + const contextProxyAccess = provider.contextProxy as unknown as { + setValues: (values: Partial) => Promise + } + await contextProxyAccess.setValues({ + mode: "debugger", + currentApiConfigName: "shared-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiKey: "shared-key", + }, + customModePrompts: { code: { roleDefinition: "shared" } }, + }) + + await providerAccess.saveViewState("mode", "architect") + await providerAccess.saveViewState("currentApiConfigName", "view-profile") + await providerAccess.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "view-key", + }) + + const values = provider.getValues() + + expect(values.mode).toBe("architect") + expect(values.currentApiConfigName).toBe("view-profile") + expect(values.apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "view-key", + }) + expect(values.customModePrompts).toEqual({ code: { roleDefinition: "shared" } }) + + await provider.dispose() + }) + + it("should update viewLocalState apiConfiguration when setValues receives flat provider settings", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/old-model", + }) + + await provider.setValues({ + apiProvider: providerIdentifiers.bedrock, + awsUseApiKey: true, + awsApiKey: "mock-key", + awsRegion: "us-east-1", + apiModelId: "anthropic.claude-opus-4-8-20261215-v1:0", + awsBedrockEndpoint: "http://127.0.0.1:4567", + awsBedrockEndpointEnabled: true, + }) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("bedrock") + expect(state.apiConfiguration.awsBedrockEndpoint).toBe("http://127.0.0.1:4567") + expect(provider["viewLocalState"].apiConfiguration?.apiProvider).toBe("bedrock") + expect(provider["viewLocalState"].apiConfiguration).not.toHaveProperty("openRouterModelId") + + await provider.dispose() + }) + + it("should persist setValue mutations for view-local mode", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.setValue("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + + await provider.dispose() + }) + + it("should persist setValues mutations for view-local API profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.setValues({ currentApiConfigName: "profile-from-set-values" }) + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "profile-from-set-values" }, + }) + + await provider.dispose() + }) + + it("should drop an unknown mode from setValues while keeping valid modes", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // This file's getModeBySlug mock resolves every slug; narrow it to the slugs + // under test so "not-a-real-mode" is rejected like the real lookup would. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + ["code", "architect"].includes(slug) ? { slug } : undefined) as typeof modesModule.getModeBySlug) + + try { + await provider.setValues({ mode: "not-a-real-mode" }) + + expect(provider.contextProxy.getValue("mode")).toBeUndefined() + expect(provider["viewLocalState"].mode).toBeUndefined() + + await provider.setValues({ mode: "architect" }) + + expect(provider.contextProxy.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + + await provider.dispose() + }) + + it("should sanitize raw viewStateId before using it as persisted viewStates key", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("tab panel/with.dots and spaces") + await provider.setValue("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + tab_panel_with_dots_and_spaces: { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("tab panel/with.dots and spaces") + + await provider.dispose() + }) + + it("should persist queued writes under the viewStateId active when the change was made", async () => { + let releaseFirstWrite!: () => void + const firstWriteStarted = new Promise((resolve) => { + mockContext.globalState.update = vi + .fn() + .mockImplementationOnce((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + resolve() + return new Promise((writeResolve) => { + releaseFirstWrite = writeResolve + }) + }) + .mockImplementation((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + return Promise.resolve() + }) + }) + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("view-a") + const firstSave = provider.saveViewState("mode", "architect") + await firstWriteStarted + await provider["setViewStateId"]("view-b") + releaseFirstWrite() + await firstSave + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-a": { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("view-b") + + await provider.dispose() + }) + + it("should preserve persisted viewStates entry when an editor provider is disposed during teardown", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("tab-to-preserve") + await provider.saveViewState("mode", "architect") + expect(provider.contextProxy.getValue("viewStates")).toHaveProperty("tab-to-preserve") + + await provider.dispose() + + expect(provider.contextProxy.getValue("viewStates")).toHaveProperty("tab-to-preserve") + }) + + it("should read viewStates fresh from storage so out-of-proxy writes are not clobbered", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("view-a") + await provider.saveViewState("mode", "architect") + + // Simulate a concurrent writer (another view's provider) updating the shared + // map directly in storage, bypassing this proxy's cache. + const stored = (await mockContext.globalState.get>("viewStates")) ?? {} + await mockContext.globalState.update("viewStates", { + ...stored, + "view-b": { mode: "debug", updatedAt: 1 }, + }) + + await provider.saveViewState("mode", "code") + + // The serialized write must have merged on top of the fresh storage value, not + // on top of this proxy's stale cache. + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-a": { mode: "code" }, + "view-b": { mode: "debug" }, + }) + + await provider.dispose() + }) + + it("should re-key durable viewStates entries from the temporary pre-launch view id", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // A change made before the stable id is registered persists under the + // temporary id so it is not lost; registration re-keys it to the stable id. + await provider.saveViewState("mode", "architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + [provider.viewId]: { mode: "architect" }, + }) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "debugger") + + const viewStates = provider.contextProxy.getValue("viewStates") as Record + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "debugger" }) + expect(viewStates[provider.viewId]).toBeUndefined() + + await provider.dispose() + }) + + it("should drop the temporary viewStates entry when a stable entry already exists", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // A stable entry already exists (e.g. a previous session persisted under a + // colliding temporary id); it must win over the temporary entry. + await provider.contextProxy.setValue("viewStates", { + [provider.viewId]: { mode: "architect", updatedAt: 1 }, + "stable-sidebar-view": { mode: "debugger", updatedAt: 2 }, + }) + + await provider["setViewStateId"]("stable-sidebar-view") + + const viewStates = provider.contextProxy.getValue("viewStates") as Record + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "debugger" }) + expect(viewStates[provider.viewId]).toBeUndefined() + expect(provider["viewLocalState"].mode).toBe("debugger") + + await provider.dispose() + }) + + it("should discard a stale loadViewState when a newer view id is registered during the load", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerAccess = provider as unknown as { + viewId: string + viewLocalState: { mode?: string; currentApiConfigName?: string } + loadViewState(): Promise + setViewStateId(id: string): Promise + } + + // Seed persisted entries under both ids through the proxy so the loads + // observe them via the cached read path: the temporary entry holds a + // pre-registration selection, the stable entry the post-registration one. + await provider.contextProxy.setValue("viewStates", { + [providerAccess.viewId]: { mode: "architect", currentApiConfigName: "ghost-profile", updatedAt: 1 }, + "stable-sidebar-view": { mode: "debug", updatedAt: 2 }, + }) + + // Hang the temporary entry's profile lookup so that load is still in flight + // when the stable id is registered. + let releaseGhost!: () => void + const ghostLoad = new Promise((resolve) => { + releaseGhost = resolve + }) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockReturnValue( + ghostLoad.then( + () => + ({ + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.anthropic, + }) as unknown as Awaited>, + ), + ) + + const staleLoad = providerAccess.loadViewState() + + // Register the stable id without awaiting its load: the re-key drops the + // temporary entry (the stable one already exists) and the registration's own + // load settles on the stable entry immediately. + const register = providerAccess.setViewStateId("stable-sidebar-view") + await register + + releaseGhost() + await staleLoad + + // The stale (temporary-id) load must not overwrite the stable id's load. + expect(providerAccess.viewLocalState).toEqual({ mode: "debug" }) + + await provider.dispose() + }) + }) + + describe("getState default values", () => { + it("should fall back to defaults for unset state values", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + const state = await provider.getState() + + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).toBe("default") + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(state.alwaysAllowReadOnly).toBe(false) + expect(state.alwaysAllowReadOnlyOutsideWorkspace).toBe(false) + expect(state.alwaysAllowWrite).toBe(false) + expect(state.alwaysAllowWriteOutsideWorkspace).toBe(false) + expect(state.alwaysAllowWriteProtected).toBe(false) + expect(state.alwaysAllowExecute).toBe(false) + expect(state.alwaysAllowMcp).toBe(false) + expect(state.alwaysAllowModeSwitch).toBe(false) + expect(state.alwaysAllowSubtasks).toBe(false) + expect(state.alwaysAllowFollowupQuestions).toBe(false) + expect(state.followupAutoApproveTimeoutMs).toBe(60000) + expect(state.diagnosticsEnabled).toBe(true) + expect(state.soundEnabled).toBe(false) + expect(state.ttsEnabled).toBe(false) + expect(state.ttsSpeed).toBe(1) + expect(state.enableCheckpoints).toBe(true) + expect(state.checkpointTimeout).toBe(DEFAULT_CHECKPOINT_TIMEOUT_SECONDS) + expect(state.terminalPowershellCounter).toBe(false) + expect(state.terminalZshClearEolMark).toBe(true) + expect(state.terminalZshOhMy).toBe(false) + expect(state.terminalZshP10k).toBe(false) + expect(state.terminalZdotdir).toBe(false) + expect(state.mcpEnabled).toBe(true) + expect(state.listApiConfigMeta).toEqual([]) + expect(state.pinnedApiConfigs).toEqual({}) + expect(state.modeApiConfigs).toEqual({}) + expect(state.customSupportPrompts).toEqual({}) + expect(state.experiments).toEqual(experimentDefault) + expect(state.autoApprovalEnabled).toBe(false) + expect(state.maxOpenTabsContext).toBe(20) + expect(state.maxWorkspaceFiles).toBe(200) + expect(state.telemetrySetting).toBe("unset") + expect(state.enableSubfolderRules).toBe(false) + expect(state.maxImageFileSize).toBe(5) + expect(state.maxTotalImageSize).toBe(20) + expect(state.historyPreviewCollapsed).toBe(false) + expect(state.reasoningBlockCollapsed).toBe(true) + expect(state.enterBehavior).toBe("send") + expect(state.codebaseIndexModels).toEqual(EMBEDDING_MODEL_PROFILES) + expect(state.codebaseIndexConfig).toEqual({ + codebaseIndexEnabled: false, + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, + codebaseIndexEmbedderBaseUrl: "", + codebaseIndexEmbedderModelId: "", + }) + expect(state.profileThresholds).toEqual({}) + expect(state.includeDiagnosticMessages).toBe(true) + expect(state.maxDiagnosticMessages).toBe(50) + expect(state.includeTaskHistoryInEnhance).toBe(true) + expect(state.includeCurrentTime).toBe(true) + expect(state.includeCurrentCost).toBe(true) + expect(state.maxGitStatusFiles).toBe(0) + expect(state.language).toBe("en") + + await provider.dispose() + }) + + it("should report a non-retired apiProvider from state instead of the anthropic fallback", async () => { + const contextProxy = new ContextProxy(mockContext) + await contextProxy.setValues({ apiProvider: providerIdentifiers.openrouter }) + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", contextProxy) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter) + + await provider.dispose() + }) + + it("should fill the apiConfiguration apiProvider from the raw state value when provider settings sanitize it away", async () => { + // "bogus-provider" is neither an active nor a retired provider, so + // ContextProxy.sanitizeProviderValues drops it from the provider + // settings; the raw state value still reaches apiConfiguration via + // the getState fill-in, which is what this assertion pins. + const contextProxy = new ContextProxy(mockContext) + const contextProxyAccess = contextProxy as unknown as { + setValues: (values: Record) => Promise + } + await contextProxyAccess.setValues({ apiProvider: "bogus-provider" }) + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", contextProxy) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("bogus-provider") + + await provider.dispose() + }) + + it("should serve the embedding model profiles default when the stored value is cleared", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // The constructor seeds codebaseIndexModels into the context; with a truthy + // stored value the ?? default is unobservable (both ?? and && forms return + // the same profiles object). Clear the stored value so the read-time default + // is the one under test. + await provider.contextProxy.setValue("codebaseIndexModels", undefined) + + const state = await provider.getState() + + expect(state.codebaseIndexModels).toBe(EMBEDDING_MODEL_PROFILES) + + await provider.dispose() + }) + }) + describe("postStateToWebviewThrottled", () => { beforeEach(() => { vi.useFakeTimers() From 3887f8e3546c2367d213f1655883a980637d98cb Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 21:43:52 +0800 Subject: [PATCH 23/34] fix(webview): rebuild F1b on main tip + own delta Rebuilding the F1b unit as main tip + its own delta cleared the conflict with main. The patch was authored against an older main, so applying it dropped the alwaysDenyUnapprovedCommands entry from getState(); restoring it with the shared default constant keeps the settings round trip complete. 410 tests pass. --- .../config/__tests__/importExport.spec.ts | 50 ++++- src/core/webview/ClineProvider.ts | 40 ++-- .../webview/__tests__/ClineProvider.spec.ts | 179 +++++++++++++----- .../ClineProvider.sticky-mode.spec.ts | 29 ++- .../__tests__/webviewMessageHandler.spec.ts | 30 +++ 5 files changed, 252 insertions(+), 76 deletions(-) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index c8a71d5c62..286c7cae47 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -364,17 +364,63 @@ describe("importExport", () => { { name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic }, ]) + // Stateful write-tracking proxy: every write path (setValues, setValue, + // setProviderSettings) is merged into `state` and recorded in `writes`, + // so the assertions below cover every payload rather than one call. + // Plain-function doubles cast once, matching the file-level mock pattern. + const makeStatefulProxy = (seed: Record) => { + const state: Record = { ...seed } + const writes: Record[] = [] + const record = (values: Record) => { + writes.push(values) + Object.assign(state, values) + } + return { + state, + writes, + proxy: Object.assign({} as ContextProxy, { + setValues: vi.fn(async (values: Record) => record(values)), + setValue: vi.fn(async (key: string, value: unknown) => record({ [key]: value })), + setProviderSettings: vi.fn(async (settings: Record) => record(settings)), + }), + } + } + + // This machine already has view state: it must survive the import untouched. + const existingViewStates = { + "existing-view": { mode: "code", currentApiConfigName: "default", updatedAt: 0 }, + } + const seeded = makeStatefulProxy({ viewStates: existingViewStates }) const result = await importSettingsFromPath("/mock/path/settings.json", { providerSettingsManager: mockProviderSettingsManager, - contextProxy: mockContextProxy, + contextProxy: seeded.proxy, 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(seeded.state.viewStates).toEqual(existingViewStates) + expect(seeded.state.mode).toBe("code") expect(result).not.toHaveProperty("globalSettings.viewStates") + + // A machine without view state must not gain any from the import. + const fresh = makeStatefulProxy({}) + const freshResult = await importSettingsFromPath("/mock/path/settings.json", { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: fresh.proxy, + customModesManager: mockCustomModesManager, + }) + + expect(freshResult.success).toBe(true) + expect(fresh.state).not.toHaveProperty("viewStates") + expect(fresh.state.mode).toBe("code") + expect(freshResult).not.toHaveProperty("globalSettings.viewStates") + + // No write path may carry the imported machine's view pins. + for (const payload of [...seeded.writes, ...fresh.writes]) { + expect(payload).not.toHaveProperty("viewStates") + } }) it("should return success: false when file content is invalid", async () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 923f965dee..be7a6ff099 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -57,7 +57,6 @@ import { getModelId, isRetiredProvider, providerIdentifiers, - PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -2312,10 +2311,10 @@ export class ClineProvider this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), // setProviderSettings writes the shared store directly, bypassing the - // view-local mutation path: also refresh this view's buffer so a stale - // loaded apiConfiguration cannot keep shadowing the new settings in - // getState(). - this._saveViewLocalStateFromMutation(providerSettings), + // view-local mutation path: clear this view's buffered apiConfiguration + // overlay (if any) so a stale loaded profile cannot keep shadowing the + // new settings in getState(). + this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), ]) // Other live views may have buffered this profile's settings earlier; @@ -2514,10 +2513,10 @@ export class ClineProvider this.setValue("currentApiConfigName", name), this.contextProxy.setProviderSettings(providerSettings), // setProviderSettings writes the shared store directly, bypassing the - // view-local mutation path: also refresh this view's buffer so a stale - // loaded apiConfiguration cannot keep shadowing the new settings in - // getState(). - this._saveViewLocalStateFromMutation(providerSettings), + // view-local mutation path: clear this view's buffered apiConfiguration + // overlay (if any) so a stale loaded profile cannot keep shadowing the + // new settings in getState(). + this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), ]) // Other live views may have buffered this profile's settings earlier; @@ -3583,6 +3582,8 @@ export class ClineProvider alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: mergedStateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + alwaysDenyUnapprovedCommands: + mergedStateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, alwaysAllowMcp: mergedStateValues.alwaysAllowMcp ?? false, alwaysAllowModeSwitch: mergedStateValues.alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: mergedStateValues.alwaysAllowSubtasks ?? false, @@ -3819,23 +3820,12 @@ export class ClineProvider } 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, - } } + + // Flat provider-settings keys (PROVIDER_SETTINGS_KEYS) are shared settings: + // they are written through the ContextProxy above and must NOT be merged + // into viewLocalState.apiConfiguration, which would turn them into a + // per-view override masking later shared updates from other views. } /** diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 439340613b..234c221c5a 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1859,21 +1859,6 @@ describe("ClineProvider", () => { 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 @@ -2261,12 +2246,112 @@ describe("ClineProvider", () => { await provider.dispose() }) - it("should merge getValues from ContextProxy with view-local values taking precedence", async () => { + it("reports the fresh global apiConfiguration after a profile activation followed by a global settings write", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("activated-id"), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "activated-profile", id: "activated-id", apiProvider: providerIdentifiers.anthropic }, + ]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.upsertProviderProfile( + "activated-profile", + { apiProvider: providerIdentifiers.anthropic }, + true, + ) + + // Simulate api.setConfiguration (src/extension/api.ts): a global-only + // write that does not refresh the view-local buffer. + await provider.contextProxy.setValues({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + }) + + const state = await provider.getState({ includeTaskHistory: false }) + + // The fresh global selection must win: the activation's buffer write must + // not mask the later shared update (regression guard for the e2e + // provider-probe suites, which start tasks after a profile activation). + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter) + await provider.dispose() + }) + + it("clears this view's buffered apiConfiguration when activating a different profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // Seed the per-view buffer with profile A's settings, as loadViewState would + // after a restart with a pinned profile. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "profile-a-key", + }) + + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("profile-b-id"), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.anthropic }, + ]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.upsertProviderProfile("profile-b", { apiProvider: providerIdentifiers.anthropic }, true) + + const state = await provider.getState({ includeTaskHistory: false }) + + // Activating profile B must clear profile A's buffered overlay so the shared + // settings (written by setProviderSettings) win; otherwise getState would + // report profile B's name with profile A's provider. + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + await provider.dispose() + }) + + it("clears this view's buffered apiConfiguration when directly activating a profile", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - const providerAccess = provider as unknown as { - saveViewState: (key: keyof ExtensionState, value: unknown) => Promise + // Seed the per-view buffer with profile A's settings, as loadViewState would + // after a restart with a pinned profile. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "profile-a-key", + }) + + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ + name: "profile-b", + id: "profile-b-id", + apiProvider: providerIdentifiers.anthropic, + }), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.anthropic }, + ]), + setModeConfig: vi.fn().mockResolvedValue(undefined), } - const contextProxyAccess = provider.contextProxy as unknown as { + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The direct-activation path (activateProviderProfileUnlocked) carries its own + // overlay-clearing call, distinct from upsertProviderProfile's. + await provider.activateProviderProfile({ name: "profile-b" }) + + const state = await provider.getState({ includeTaskHistory: false }) + + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + await provider.dispose() + }) + + it("should merge getValues from ContextProxy with view-local values taking precedence", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const contextProxyAccess = provider.contextProxy as { setValues: (values: Partial) => Promise } await contextProxyAccess.setValues({ @@ -2279,9 +2364,9 @@ describe("ClineProvider", () => { customModePrompts: { code: { roleDefinition: "shared" } }, }) - await providerAccess.saveViewState("mode", "architect") - await providerAccess.saveViewState("currentApiConfigName", "view-profile") - await providerAccess.saveViewState("apiConfiguration", { + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "view-profile") + await provider.saveViewState("apiConfiguration", { apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "view-key", }) @@ -2299,7 +2384,7 @@ describe("ClineProvider", () => { await provider.dispose() }) - it("should update viewLocalState apiConfiguration when setValues receives flat provider settings", async () => { + it("should keep flat provider settings out of the view-local buffer", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) await provider.saveViewState("apiConfiguration", { @@ -2317,12 +2402,16 @@ describe("ClineProvider", () => { awsBedrockEndpointEnabled: true, }) - const state = await provider.getState() - - expect(state.apiConfiguration.apiProvider).toBe("bedrock") - expect(state.apiConfiguration.awsBedrockEndpoint).toBe("http://127.0.0.1:4567") - expect(provider["viewLocalState"].apiConfiguration?.apiProvider).toBe("bedrock") - expect(provider["viewLocalState"].apiConfiguration).not.toHaveProperty("openRouterModelId") + // Flat provider-settings keys are shared settings: they must flow through + // the ContextProxy only and must not be merged into the view-local buffer, + // which would turn them into a per-view override masking later shared + // updates from other views. The explicit view-local override survives. + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/old-model", + }) + expect(provider.contextProxy.getValue("apiProvider")).toBe(providerIdentifiers.bedrock) + expect(provider.contextProxy.getValue("awsBedrockEndpoint")).toBe("http://127.0.0.1:4567") await provider.dispose() }) @@ -2514,18 +2603,12 @@ describe("ClineProvider", () => { it("should discard a stale loadViewState when a newer view id is registered during the load", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - const providerAccess = provider as unknown as { - viewId: string - viewLocalState: { mode?: string; currentApiConfigName?: string } - loadViewState(): Promise - setViewStateId(id: string): Promise - } // Seed persisted entries under both ids through the proxy so the loads // observe them via the cached read path: the temporary entry holds a // pre-registration selection, the stable entry the post-registration one. await provider.contextProxy.setValue("viewStates", { - [providerAccess.viewId]: { mode: "architect", currentApiConfigName: "ghost-profile", updatedAt: 1 }, + [provider.viewId]: { mode: "architect", currentApiConfigName: "ghost-profile", updatedAt: 1 }, "stable-sidebar-view": { mode: "debug", updatedAt: 2 }, }) @@ -2536,29 +2619,28 @@ describe("ClineProvider", () => { releaseGhost = resolve }) vi.spyOn(provider.providerSettingsManager, "getProfile").mockReturnValue( - ghostLoad.then( - () => - ({ - name: "ghost-profile", - id: "ghost-id", - apiProvider: providerIdentifiers.anthropic, - }) as unknown as Awaited>, + ghostLoad.then(() => + Object.assign({} as Awaited>, { + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.anthropic, + }), ), ) - const staleLoad = providerAccess.loadViewState() + const staleLoad = provider["loadViewState"]() // Register the stable id without awaiting its load: the re-key drops the // temporary entry (the stable one already exists) and the registration's own // load settles on the stable entry immediately. - const register = providerAccess.setViewStateId("stable-sidebar-view") + const register = provider["setViewStateId"]("stable-sidebar-view") await register releaseGhost() await staleLoad // The stale (temporary-id) load must not overwrite the stable id's load. - expect(providerAccess.viewLocalState).toEqual({ mode: "debug" }) + expect(provider["viewLocalState"]).toEqual({ mode: "debug" }) await provider.dispose() }) @@ -2649,7 +2731,10 @@ describe("ClineProvider", () => { // settings; the raw state value still reaches apiConfiguration via // the getState fill-in, which is what this assertion pins. const contextProxy = new ContextProxy(mockContext) - const contextProxyAccess = contextProxy as unknown as { + // A single structural cast: the raw-state write must carry an + // un-sanitizable apiProvider value, which the typed setValues(RooCodeSettings) + // signature deliberately rejects. + const contextProxyAccess = contextProxy as { setValues: (values: Record) => Promise } await contextProxyAccess.setValues({ apiProvider: "bogus-provider" }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 01d33c035c..26aebbe4dd 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -459,6 +459,31 @@ describe("ClineProvider - Sticky Mode", () => { ) }) + it("should sync the view-local mode buffer when switching modes after a restored view state", async () => { + // Simulate the history-restore path: saveViewState is what + // createTaskWithHistoryItem uses to pin a saved mode into the + // view-local buffer, leaving a stale mode there until the next mutation. + await provider.saveViewState("mode", "code") + + // Global-only mode switch with no active task. + await provider.handleModeSwitch("architect") + + // The durable global write still happens... + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + + // ...and the in-memory buffer must not keep serving the stale restored + // mode: getValues() merges viewLocalState on top of the ContextProxy + // values, so an unsynced buffer would hide the fresh mode from consumers. + expect(provider["viewLocalState"].mode).toBe("architect") + + // The durable per-view write must land too: a regression that left the + // persisted entry on the stale restored mode would reload it on restart. + // setValue awaits the serialized write queue, so the entry is settled here. + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] + expect(persisted.mode).toBe("architect") + expect(provider.getValues().mode).toBe("architect") + }) + it("bails out before any task write when the mutation signal is already aborted", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. // The literal is asserted as Partial so its private members (_taskMode, @@ -1031,11 +1056,11 @@ describe("ClineProvider - Sticky Mode", () => { // Restore the task from history await provider.createTaskWithHistoryItem(historyItem) - // 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. + // Verify that history restoration reaches both the view-local pin and public state. 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/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index dcd70f92f1..6e04b91e92 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2138,6 +2138,25 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(calls.at(-1)).toEqual([true]) }) + // The webviewDidLaunch tests below replace these mockClineProvider members with + // per-test doubles. Snapshot the module-level originals at collection time and + // restore them in the afterEach below so the launch stubs never leak into other + // tests of this file. + // Single structural cast: the class types these members as a method / a + // readonly property, which cannot be re-assigned to swap in a per-test double. + const launchSuiteSnapshot = (() => { + const view = mockClineProvider as { + getMcpHub: unknown + providerSettingsManager: unknown + getStateToPostToWebview: unknown + } + return { + getMcpHub: view.getMcpHub, + providerSettingsManager: view.providerSettingsManager, + getStateToPostToWebview: view.getStateToPostToWebview, + } + })() + // CodeRabbit follow-up on the finding #12 fix: webviewDidLaunch's telemetry init read state // via an async provider.getStateToPostToWebview().then(...) continuation, outside // telemetrySettingQueue -- so it could resolve after a concurrent "telemetrySetting" message @@ -2306,4 +2325,15 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) + + afterEach(() => { + const view = mockClineProvider as { + getMcpHub: unknown + providerSettingsManager: unknown + getStateToPostToWebview: unknown + } + view.getMcpHub = launchSuiteSnapshot.getMcpHub + view.providerSettingsManager = launchSuiteSnapshot.providerSettingsManager + view.getStateToPostToWebview = launchSuiteSnapshot.getStateToPostToWebview + }) }) From 8e4afd7767ace5b6c3c03d644c670697f7049f2f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 09:28:33 +0800 Subject: [PATCH 24/34] fix(lint): correct the recorded suppression count for the sticky-mode spec The recorded count was 36 while the file has 26 @typescript-eslint/no-explicit-any violations, so `eslint . --max-warnings=0` fails with "There are suppressions left that do not occur anymore" on CI. Counts must never increase; this brings the record back to the actual count. --- src/eslint-suppressions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index d0a2decc59..a9369f5e19 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": 36 + "count": 26 } }, "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { @@ -1724,4 +1724,4 @@ "count": 1 } } -} +} \ No newline at end of file From cd90389d1c3826c34f0da3b2e2f6d6831e8805b7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 09:43:02 +0800 Subject: [PATCH 25/34] test(webview): seed the re-key entries through the write path The re-key guard in F1a only moves an entry this instance authored, so seeding storage directly no longer exercises the re-key. Seeding through savePersistedViewState matches how a pre-launch entry is actually created. --- src/core/webview/__tests__/ClineProvider.spec.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 234c221c5a..eda4f96817 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1374,8 +1374,9 @@ describe("ClineProvider", () => { 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 } }) + // Seed a pre-launch entry under the temporary id through the real write path, + // so the re-key has real work to do and records this instance as its author. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) const setValueSpy = vi.spyOn(contextProxy, "setValue").mockRejectedValue(new Error("storage down")) @@ -1455,8 +1456,9 @@ describe("ClineProvider", () => { 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 } }) + // Seed through the write path so this instance is the author of the entry under + // its temporary id; an entry authored by another session must not be adopted. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) await provider["setViewStateId"]("stable-sidebar-view") expect(mockContext.globalState.get("viewStates")).toEqual({ "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, @@ -1466,7 +1468,10 @@ describe("ClineProvider", () => { 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", { + // Author the temporary entry through the write path so this instance owns it, + // then add a stable entry that already exists in storage. + await provider["savePersistedViewState"]({ mode: "temp-mode", updatedAt: 1 }) + await provider.contextProxy.setValue("viewStates", { [provider.viewId]: { mode: "temp-mode", updatedAt: 1 }, "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, }) From 74f997543115a2a4463b78840adc428817621d78 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 10:12:01 +0800 Subject: [PATCH 26/34] fix(webview): drop the overlay when the surviving profile's settings cannot be resolved When getProfile() fails for the profile being activated, no replacement settings exist, but the pin was still re-pointed. This view's nested overlay and every other view pinned to the deleted profile kept serving the deleted profile's configuration under the new name, so getState() reported a name whose settings belonged to a deleted profile, including its keys. The overlay is now cleared in both paths. Tests: 250 passed in ClineProvider.spec.ts; ESLint clean with --max-warnings=0. --- src/core/webview/ClineProvider.ts | 20 +++++++-- .../webview/__tests__/ClineProvider.spec.ts | 45 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index be7a6ff099..cd759e8233 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2420,7 +2420,16 @@ export class ClineProvider await this.contextProxy.setValue("currentApiConfigName", profileToActivate) } - if ((deletedWasGlobal || viewWasPinnedToDeleted) && survivingSettings) { + if (deletedWasGlobal || viewWasPinnedToDeleted) { + if (!survivingSettings) { + // No settings could be resolved for the survivor. The pin now names a + // profile whose configuration is unknown, so drop the overlay instead of + // serving the deleted profile's settings under the new name. + if (viewWasPinnedToDeleted) { + await this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }) + } + } else { + // The deleted profile was the active one (globally, or for this view), so // the shared provider keys still carry its settings; replace them so // getState() reports the surviving profile's configuration. @@ -2433,6 +2442,7 @@ export class ClineProvider // settings. A view pinned to another profile keeps its own overlay. await this._saveViewLocalStateFromMutation({ apiConfiguration: survivingSettings }) } + } } // Re-pin other live views still buffered on the deleted profile: their @@ -2604,9 +2614,11 @@ export class ClineProvider currentApiConfigName: replacementName, } - if (replacementSettings) { - values.apiConfiguration = replacementSettings - } + // Assigning even when undefined is what clears the overlay: without a + // resolvable replacement the re-pointed pin names a profile whose settings + // are unknown, so the deleted profile's configuration must not survive under + // the new name. + values.apiConfiguration = replacementSettings // Direct private access: compile-time safe across sibling instances. await instance._saveViewLocalStateFromMutation(values) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index eda4f96817..8c2529807b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2094,6 +2094,51 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("drops the view overlay when the surviving profile's settings cannot be resolved", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const thirdProfile: ProviderSettingsEntry = { + name: "third-profile", + id: "third-id", + apiProvider: providerIdentifiers.openrouter, + } + // Three profiles so the deletion still has a surviving profile to activate. + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile, thirdProfile]) + // The global selection names a surviving profile while this view is pinned to + // the profile being deleted, so the re-point still happens. + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + // This view is pinned to the profile being deleted and still holds its + // settings in the nested overlay. + provider["viewLocalState"].currentApiConfigName = "old-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "deleted-profile-secret", + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockRejectedValue(new Error("profile unreadable")), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + + await provider.deleteProviderProfile(oldProfile) + + // The pin now names a profile whose settings are unknown, so the deleted + // profile's configuration must not survive under the new name. + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + await provider.dispose() + }) + it("leaves the view buffer untouched when the deleted profile is neither globally active nor view-pinned", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) const oldProfile: ProviderSettingsEntry = { From 8c9c55fd3ef0aadea86d2777ffb15624039a9d84 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 14:56:29 +0800 Subject: [PATCH 27/34] fix(webview): validate the webview-supplied view state id before adopting it The stable view state id arrives over the webview postMessage channel, so the declared string type is not a guarantee: a crafted message could carry a non-string value (which the sanitizer would crash on), an unbounded string (unbounded growth in the shared viewStates map), or an id another live view already owns (adopting it would load that view's pinned profile, overlay and mode here, and let this webview overwrite that view's persisted entry). setViewStateId now rejects non-strings, bounds the key length, and refuses ids claimed by another live instance; a refused view stays on its temporary id and keeps working against the shared state. Proofs: ClineProvider.sticky-mode.spec 32/32 (3 new tests: non-string, oversized, claimed-by-another-view); negative controls: disabling the ownership check reddens exactly the claim test, widening the length bound reddens exactly the oversized test, restores byte-exact (hash match); eslint --max-warnings=0 clean; tsc clean except the two known local telemetry-dist noise lines; prettier ALL-MATCHED on touched files. --- src/core/webview/ClineProvider.ts | 37 ++++++++++++++++++- .../ClineProvider.sticky-mode.spec.ts | 35 ++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c9cdf40d6f..111d298456 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -215,6 +215,7 @@ export class ClineProvider private static activeInstances: Set = new Set() private static nextViewId = 0 private static readonly MAX_PERSISTED_VIEW_STATES = 50 + private static readonly MAX_VIEW_STATE_ID_LENGTH = 64 private static persistedViewStateWriteQueue: Promise = Promise.resolve() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] @@ -704,7 +705,18 @@ export class ClineProvider * 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, "_") + // The webview generates this id on purpose - it has to survive a webview reload, + // which recreates the view on both sides - so the host cannot mint it instead. + // What the host owns is which view may hold a given persisted key, so everything + // below validates the supplied value before it becomes one. + if (typeof viewStateId !== "string") { + // A crafted postMessage can carry any JSON value here even though the message + // type declares a string; the persisted key must stay a string. + this.log(`[setViewStateId] Ignoring a non-string viewStateId for view ${this.viewId}.`) + return + } + + const normalizedViewStateId = viewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") if ( !normalizedViewStateId || @@ -716,6 +728,29 @@ export class ClineProvider return } + // Bound the key: the webview generates a UUID, so a much longer value is not a + // view id, and an unbounded key is unbounded growth in the shared viewStates map. + if (normalizedViewStateId.length > ClineProvider.MAX_VIEW_STATE_ID_LENGTH) { + this.log( + `[setViewStateId] Ignoring an oversized viewStateId (${normalizedViewStateId.length} chars) for view ${this.viewId}.`, + ) + return + } + + const claimant = ClineProvider.getAllInstances().find( + (instance) => instance !== this && instance.viewStateId === normalizedViewStateId, + ) + if (claimant) { + // Ownership check: a key another live view holds must not be adopted. Doing so + // would load that view's pinned profile, nested overlay, and mode into this + // provider, and this webview could then overwrite that view's persisted entry. + // Staying on the temporary id keeps this view working against the shared state. + this.log( + `[setViewStateId] Refusing viewStateId ${normalizedViewStateId} for view ${this.viewId}: already claimed by live view ${claimant.viewId}.`, + ) + return + } + const previousViewStateId = this.viewStateId this.viewStateId = normalizedViewStateId diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index b0b37c251d..e0ec506ce2 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -121,6 +121,8 @@ vi.mock("@roo-code/cloud", () => ({ get instance() { return { isAuthenticated: vi.fn().mockReturnValue(false), + on: vi.fn(), + off: vi.fn(), } }, }, @@ -1682,4 +1684,37 @@ describe("ClineProvider - Sticky Mode", () => { expect(states["stable-test-view"]).toBeUndefined() }) }) + + describe("setViewStateId input validation", () => { + it("ignores a non-string viewStateId from a crafted message", async () => { + const before = provider["viewStateId"] + // A crafted postMessage can carry any JSON value despite the declared type. + await provider.setViewStateId(42 as unknown as string) + expect(provider["viewStateId"]).toBe(before) + }) + + it("ignores an oversized viewStateId", async () => { + const before = provider["viewStateId"] + await provider.setViewStateId("x".repeat(200)) + expect(provider["viewStateId"]).toBe(before) + }) + + it("refuses a viewStateId already claimed by another live view", async () => { + const other = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + await other.setViewStateId("claimed-by-other-view") + await provider.setViewStateId("claimed-by-other-view") + expect(provider["viewStateId"]).not.toBe("claimed-by-other-view") + expect(other["viewStateId"]).toBe("claimed-by-other-view") + } finally { + await other.dispose() + } + }) + }) }) From e0e540e2b2bb9b7ab7a45f1cfd972788e314eed8 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 15:46:42 +0800 Subject: [PATCH 28/34] fix(webview): refresh pinned views on non-activating saves; treat the view overlay as a complete profile Two review findings on the view-local profile buffer: 1. A save that does not activate (upsertProviderProfile with activate=false, and the zoo-gateway callback's non-active profile path) rewrote the profile's settings on disk but left every view pinned to it serving the pre-save snapshot from its buffer. The non-activating branch now refreshes pinned sibling views (and this view when it holds the pin), and the callback routes non-active profiles through upsertProviderProfile instead of a bare saveConfig so the refresh cannot be forgotten at that call site. 2. getState() spread the pinned overlay over the shared provider settings, so any key the pinned profile never defined leaked in from whatever profile the shared store points at (another profile's model id, region, tuning). The overlay is a complete profile snapshot: it is now used in place, with only apiProvider falling back to the shared state when the profile omits one. Proofs: ClineProvider.spec 283/283 (2 new tests: pinned-sibling refresh on non-activating save; no shared-key leak through the overlay; callback test updated to assert the upsert path); negative controls: dropping the else-branch refresh reddens exactly the refresh test, reverting the merge semantics reddens exactly the leak test, restores byte-exact (hash match); eslint --max-warnings=0 clean; tsc clean except the two known local telemetry-dist noise lines; prettier clean on touched files. --- src/core/webview/ClineProvider.ts | 33 ++++- .../webview/__tests__/ClineProvider.spec.ts | 118 +++++++++++++++++- 2 files changed, 141 insertions(+), 10 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 111d298456..a8a07e8b3d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2411,6 +2411,15 @@ export class ClineProvider await this.persistStickyProviderProfileToCurrentTask(name) } else { await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) + + // A save that does not activate still changes the profile's settings, and + // every view pinned to it buffers a full copy of them. Refresh those views + // (and this one when it is the pinned view) or their getState() keeps + // serving the settings captured before the save under the same name. + if (this.pinnedProfileName === name) { + await this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + } + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } await this.postStateToWebview() @@ -2842,8 +2851,10 @@ export class ClineProvider // picks up the new token immediately for the current task. await this.upsertProviderProfile(entry.name, updated, true) } else { - // Non-active profiles just need the token saved to disk. - await this.providerSettingsManager.saveConfig(entry.name, updated) + // Non-active profiles need the token saved to disk and the views pinned + // to them refreshed: a direct saveConfig leaves a pinned view serving the + // old token until it reloads. + await this.upsertProviderProfile(entry.name, updated, false) } } } @@ -3656,11 +3667,21 @@ export class ClineProvider const taskSyncEnabled: boolean = false // Return the same structure as before. + // A view-local profile is a complete snapshot, not a patch over the shared store. + // Spreading it on top of providerSettings would let a key the profile never defined + // leak in from another profile, which ContextProxy.setProviderSettings deliberately + // prevents by clearing omitted keys. Use the view's own settings in place and take + // only apiProvider from the shared state when the profile does not carry one. + const viewApiConfiguration = mergedStateValues.apiConfiguration + const resolvedApiConfiguration = viewApiConfiguration + ? { + ...viewApiConfiguration, + apiProvider: viewApiConfiguration.apiProvider ?? providerSettings.apiProvider, + } + : providerSettings + return { - apiConfiguration: { - ...providerSettings, - ...mergedStateValues.apiConfiguration, - }, + apiConfiguration: resolvedApiConfiguration, lastShownAnnouncementId: mergedStateValues.lastShownAnnouncementId, customInstructions: mergedStateValues.customInstructions, apiModelId: mergedStateValues.apiModelId, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 796446b8fe..24667af65f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -500,6 +500,13 @@ describe("ClineProvider", () => { let mockPostMessage: any let updateGlobalStateSpy: any + afterEach(() => { + // Tests construct providers directly and many never dispose them; clear the + // static registry so a leaked instance cannot claim stable view state ids + // ahead of a later test. + ClineProvider["activeInstances"] = new Set() + }) + beforeEach(() => { vi.clearAllMocks() @@ -2129,6 +2136,10 @@ describe("ClineProvider", () => { }) // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. provider.providerSettingsManager = { getProfile: getProfileSpy } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + await provider.contextProxy.setValue( "viewStates", mockContext.globalState.get("viewStates"), @@ -2141,7 +2152,6 @@ describe("ClineProvider", () => { }) expect(getProfileSpy).toHaveBeenCalledWith({ name: "my-profile" }) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) - await writer.dispose() await provider.dispose() }) @@ -2269,6 +2279,10 @@ describe("ClineProvider", () => { } // A pre-load buffer write that was never persisted must not be merged over the load. provider["viewLocalState"] = { mode: "architect" } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + await provider.contextProxy.setValue( "viewStates", mockContext.globalState.get("viewStates"), @@ -2280,7 +2294,6 @@ describe("ClineProvider", () => { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x", }) - await writer.dispose() await provider.dispose() }) @@ -2671,6 +2684,10 @@ describe("ClineProvider", () => { }), } vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + await provider.contextProxy.setValue( "viewStates", mockContext.globalState.get("viewStates"), @@ -2683,7 +2700,6 @@ describe("ClineProvider", () => { // The buffer must track the activated profile so getValues() agrees with the proxy. expect(provider.getValues().currentApiConfigName).toBe("new-profile") expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("new-profile") - await writer.dispose() await provider.dispose() }) @@ -6563,6 +6579,11 @@ describe("getTelemetryProperties", () => { const { CloudService } = await import("@roo-code/cloud") const mockCloudService = { isAuthenticated: vi.fn().mockReturnValue(true), + + // Provider dispose detaches the settings listener; later tests construct + // and dispose providers against this surviving instance getter. + on: vi.fn(), + off: vi.fn(), } // Update the existing mock @@ -6581,6 +6602,11 @@ describe("getTelemetryProperties", () => { const { CloudService } = await import("@roo-code/cloud") const mockCloudService = { isAuthenticated: vi.fn().mockReturnValue(false), + + // Provider dispose detaches the settings listener; later tests construct + // and dispose providers against this surviving instance getter. + on: vi.fn(), + off: vi.fn(), } // Update the existing mock @@ -6622,6 +6648,11 @@ describe("getTelemetryProperties", () => { isAuthenticated: vi.fn().mockImplementation(() => { throw new Error("Authentication check error") }), + + // Provider dispose detaches the settings listener; later tests construct + // and dispose providers against this surviving instance getter. + on: vi.fn(), + off: vi.fn(), } // Update the existing mock @@ -8126,12 +8157,15 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }), true, ) - expect(saveConfig).toHaveBeenCalledWith( + // The non-active profile now goes through upsertProviderProfile so the views + // pinned to it are refreshed, not just the file on disk. + expect(upsertSpy).toHaveBeenCalledWith( "Backup Zoo", expect.objectContaining({ zooSessionToken: "new-token", zooGatewayBaseUrl: "https://www.zoocode.dev/api/gateway/v1", }), + false, ) }) @@ -8228,4 +8262,80 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) }) }) + + describe("profile save without activation and overlay completeness", () => { + it("refreshes views pinned to a profile saved without activation", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + // The sibling loaded the profile earlier: its buffer pins the name and + // carries the settings snapshot taken at that load. + sibling["viewLocalState"].currentApiConfigName = "saved-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-key", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("saved-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + + await provider.upsertProviderProfile( + "saved-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" }, + false, + ) + + // A save that does not activate still rewrites the profile; the pinned + // sibling must not keep serving the pre-save snapshot. + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) + } finally { + await sibling.dispose() + } + }) + + it("does not leak shared provider keys into a pinned view's api configuration", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + // The shared store sits on a profile with keys the pinned profile never + // sets; spreading the overlay over the shared keys would report a mixed + // configuration (e.g. another profile's model id and region). + await provider.contextProxy.setValue("apiProvider", providerIdentifiers.bedrock) + await provider.contextProxy.setValue("apiModelId", "anthropic.claude-v1") + await provider.contextProxy.setValue("awsRegion", "us-east-1") + provider["viewLocalState"].currentApiConfigName = "pinned-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "k", + } + + // The cloud doubles in this suite make getState() log its defensive catch + // branches; silence them so teardown has no pending console writes. + vi.spyOn(console, "error").mockImplementation(() => {}) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(state.apiConfiguration.apiKey).toBe("k") + expect(state.apiConfiguration.apiModelId).toBeUndefined() + expect(state.apiConfiguration.awsRegion).toBeUndefined() + }) + }) }) From 74825b2c3acb5d4f08c81bf6f280f67724dc9249 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 16:27:38 +0800 Subject: [PATCH 29/34] fix(webview): read the view overlay from the buffer, not the merged state The overlay-completeness fix read the overlay from the merged state values, which also carry the raw apiConfiguration state key. That key is a partial patch written by shared-store paths, not a view snapshot: when a view had no overlay but the raw key held a partial object, getState() served only that partial object and dropped the shared provider settings the previous merge produced (activation tests caught exactly this: the activated profile's model id vanished). Read the overlay from the view buffer itself, where a present value is by contract a complete profile and an absent one means the shared store serves the settings. Proofs: ClineProvider.parallelMode.spec green (the six activation/upsert/ delete synchronization tests that regressed), ClineProvider.spec green including the overlay-completeness tests, sticky-mode and webviewMessageHandler green; negative control: reverting the overlay to a shallow merge over the shared settings still reddens exactly the leak test; restores byte-exact (hash match); eslint --max-warnings=0 clean; prettier clean; tsc clean except the two known local telemetry-dist noise lines. --- src/core/webview/ClineProvider.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a8a07e8b3d..6d185474e7 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3672,7 +3672,9 @@ export class ClineProvider // leak in from another profile, which ContextProxy.setProviderSettings deliberately // prevents by clearing omitted keys. Use the view's own settings in place and take // only apiProvider from the shared state when the profile does not carry one. - const viewApiConfiguration = mergedStateValues.apiConfiguration + // The overlay is read from the buffer itself: the raw apiConfiguration state key + // is a partial patch from shared-store paths, not a view snapshot. + const viewApiConfiguration = this.viewLocalState.apiConfiguration const resolvedApiConfiguration = viewApiConfiguration ? { ...viewApiConfiguration, From 08af5cbf82c79c9d91ef3c842f4d9eee2f2d2016 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 17:09:15 +0800 Subject: [PATCH 30/34] fix(webview): rebuild active task handlers when a pinned profile is saved A non-activating save refreshed the pinned views' buffers but not their active tasks' API handlers, and the posted state prefers the task's own apiConfiguration: a conversation running in a view pinned to the saved profile kept sending the old token (e.g. after the Zoo Gateway callback rotated it) even though the view's buffer had caught up. The refresh now rebuilds the affected tasks' handlers in the sibling views, and the non-activating branch rebuilds this view's own handler when it holds the pin. Proofs: ClineProvider.spec 258/258 (the pinned-sibling refresh test now asserts the active task's handler is rebuilt with the saved settings, not just the private buffer); negative control: dropping the rebuild from the refresh reddens exactly that assertion, restores byte-exact (hash match); sticky-mode 32/32, webviewMessageHandler 82/82; eslint --max-warnings=0 clean; prettier clean. --- src/core/webview/ClineProvider.ts | 8 ++++++++ .../webview/__tests__/ClineProvider.spec.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6d185474e7..d9ff4dd646 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2418,6 +2418,7 @@ export class ClineProvider // serving the settings captured before the save under the same name. if (this.pinnedProfileName === name) { await this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) } await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } @@ -2672,6 +2673,13 @@ export class ClineProvider affected.map(async (instance) => { // Direct private access: compile-time safe across sibling instances. await instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + + // The active task of a pinned view holds its own API handler built from + // the profile's settings: rebuild it with the saved settings or the + // running conversation keeps sending the old token even though the + // view's buffer has caught up. Direct private access across sibling + // instances of the same class is compile-time safe. + instance.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) await instance.postStateToWebview() }), ) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 24667af65f..ac11e6e386 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -8286,6 +8286,17 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { apiKey: "old-key", } vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + // The sibling has an active task built from the pre-save settings. + // Task is a class; the double assertion is the only way to attach a + // minimal double without the full provider/services graph. + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "old-key" }, + updateApiConfiguration: vi.fn(), + } + vi.spyOn(sibling, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // @ts-ignore - Replace providerSettingsManager with a test double. provider.providerSettingsManager = { @@ -8306,6 +8317,13 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key", }) + + // The active task's handler must be rebuilt with the saved settings, + // not just the view buffer. + expect(siblingTask.updateApiConfiguration).toHaveBeenCalledWith({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) } finally { await sibling.dispose() } From ef73863f3bf9a4b3312d4fb370c660c96b743a75 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 17:52:05 +0800 Subject: [PATCH 31/34] test: fix describe nesting, strengthen prune assertion, make stale-entry check deterministic, restore timers in finally Review round on this unit's test quality: the viewStates describe block was nested inside the alwaysDenyUnapprovedCommands block, so reported test names carried the wrong subject; the prune test asserted survival with toDefined(), which passes even if pruning strips fields - the surviving entries are fully known, so assert their exact shape (matching the parallel-mode suite); the stale-temporary-entry test waited on a fixed 10 ms timer, so a slow load could let the negative assertion pass vacuously - await the load itself; the readiness-cancel test left fake timers active when an assertion failed before the restore line, hanging a later real-timer test - restore in a finally block. Proofs: global-settings 9/9, ClineProvider.spec 294/294, sticky-mode 33/33, api-task-control 23/23; negative controls: pruning that strips updatedAt reddens exactly the prune test, a loadViewState that adopts the unowned temporary-id entry reddens exactly the stale-entry test, restores byte-exact (hash match); eslint --max-warnings=0 clean (src and types); prettier clean. --- src/core/webview/__tests__/ClineProvider.spec.ts | 4 ++-- src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index ac11e6e386..5b54bb1450 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2083,8 +2083,8 @@ describe("ClineProvider", () => { 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-1"]).toEqual({ mode: "mode-1", updatedAt: 7 }) + expect(pruned["view-54"]).toEqual({ mode: "mode-54", updatedAt: 48 }) expect(pruned["view-8"]).toBeUndefined() await provider.dispose() }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index e0ec506ce2..b6e4a7019a 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -1613,8 +1613,9 @@ describe("ClineProvider - Sticky Mode", () => { const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) expect(fresh.viewId).toBe(tempId) - // Let the constructor's fire-and-forget loadViewState settle. - await new Promise((resolve) => setTimeout(resolve, 10)) + // Await the load itself instead of a fixed timer: the negative + // assertion must run after the load has definitely finished. + await fresh["loadViewState"]() // The unowned temporary-id entry must not be adopted... expect(fresh["viewLocalState"]).toStrictEqual({}) From 6efdad36e428edaa0c16ed4818dca72f9ee28b56 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:19:14 +0800 Subject: [PATCH 32/34] fix: rebuild re-pinned siblings' tasks on profile deletion Review-round fix on the shared code this unit carries: rePinViewLocalStateForDeletedProfile updated each sibling's buffer but not its running task: the posted state prefers the task's own apiConfiguration, so a conversation in a view pinned to the deleted profile kept sending the deleted profile's settings - the re-pin now rebuilds the sibling's API handler with the replacement's settings and re-points the task's sticky profile name, mirroring the activation path. Tested by the sibling re-pin assertions in the unit that owns the parallel mode suite; this unit's suites confirm the port is behavior-preserving for its own coverage. Proofs: ClineProvider.spec and sticky-mode green; eslint --max-warnings=0 clean; prettier clean. --- src/core/webview/ClineProvider.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d9ff4dd646..d339644b1e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2719,6 +2719,16 @@ export class ClineProvider // Direct private access: compile-time safe across sibling instances. await instance._saveViewLocalStateFromMutation(values) + // The active task of a view pinned to the deleted profile holds its own API + // handler and sticky profile name built from the deleted profile: rebuild + // the handler with the replacement's settings and re-point the sticky name, + // or the running conversation keeps sending the deleted profile's + // configuration even though the view's buffer was re-pointed (the posted + // state prefers the task's own apiConfiguration). + if (replacementSettings) { + instance.updateTaskApiHandlerIfNeeded(replacementSettings, { forceRebuild: true }) + } + await instance.persistStickyProviderProfileToCurrentTask(replacementName) await instance.postStateToWebview() }), ) From 50aa36754c5a8cc4be458d9125cc0ab451faee91 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 21:32:20 +0800 Subject: [PATCH 33/34] fix(webview): scope profile-save task rebuilds to the task's own profile Port of the sibling-unit fix: a non-activating profile save force-rebuilt the API handler of every view pinned to the saved profile, including views whose active task follows a different sticky profile. Both rebuild sites (the acting view's pinned branch and the pinned-sibling refresh) are now guarded by a taskApiConfigName match; the pinned view's overlay still refreshes. Regression tests cover both sites. Test hygiene: prune assertions compare full kept entries; the two re-key storage tests seed viewStates through the durable write path so their assertions can fail. --- src/core/webview/ClineProvider.ts | 18 +++- .../webview/__tests__/ClineProvider.spec.ts | 100 +++++++++++++++++- .../ClineProvider.sticky-mode.spec.ts | 16 +-- 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d339644b1e..c9c4586b9a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2418,7 +2418,13 @@ export class ClineProvider // serving the settings captured before the save under the same name. if (this.pinnedProfileName === name) { await this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + // Only rebuild when the task actually runs under the saved profile: a + // view pinned to this profile can hold a task that follows a different + // sticky profile, and the forced rebuild would silently switch that + // conversation to the saved profile's settings. + if (this.getCurrentTask()?.taskApiConfigName === name) { + this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + } } await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } @@ -2677,9 +2683,13 @@ export class ClineProvider // The active task of a pinned view holds its own API handler built from // the profile's settings: rebuild it with the saved settings or the // running conversation keeps sending the old token even though the - // view's buffer has caught up. Direct private access across sibling - // instances of the same class is compile-time safe. - instance.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + // view's buffer has caught up. Only rebuild when the task actually runs + // under the saved profile: a task that follows a different sticky + // profile must keep its own settings. Direct private access across + // sibling instances of the same class is compile-time safe. + if (instance.getCurrentTask()?.taskApiConfigName === name) { + instance.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + } await instance.postStateToWebview() }), ) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 5b54bb1450..2b48c9c6c3 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1869,8 +1869,8 @@ describe("ClineProvider", () => { const pruned = provider["prunePersistedViewStates"](states) expect(Object.keys(pruned)).toHaveLength(50) - expect(pruned["view-54"]).toBeDefined() - expect(pruned["view-5"]).toBeDefined() + expect(pruned["view-54"]).toEqual({ mode: "mode-54", updatedAt: 54 }) + expect(pruned["view-5"]).toEqual({ mode: "mode-5", updatedAt: 5 }) expect(pruned["view-4"]).toBeUndefined() await provider.dispose() @@ -8292,6 +8292,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { // minimal double without the full provider/services graph. const siblingTask = { apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "old-key" }, + taskApiConfigName: "saved-profile", updateApiConfiguration: vi.fn(), } vi.spyOn(sibling, "getCurrentTask").mockReturnValue( @@ -8329,6 +8330,101 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { } }) + it("does not rebuild a pinned sibling's task that runs under another profile", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + // The sibling pins the saved profile, but its active task follows a + // different sticky profile: the save must refresh the view overlay + // without switching that conversation to the saved profile's settings. + sibling["viewLocalState"].currentApiConfigName = "saved-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-key", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "other-key" }, + taskApiConfigName: "other-profile", + updateApiConfiguration: vi.fn(), + } + vi.spyOn(sibling, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("saved-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + + await provider.upsertProviderProfile( + "saved-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" }, + false, + ) + + // The pinned view's overlay still catches up with the saved settings... + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) + // ...but the task runs under another profile: its handler must not be + // switched to the saved profile's settings. + expect(siblingTask.updateApiConfiguration).not.toHaveBeenCalled() + // Let queued view-local persistence settle while the console spies are + // still installed, so teardown has no pending writes. + await new Promise((resolve) => setTimeout(resolve, 50)) + } finally { + await sibling.dispose() + } + }) + + it("does not rebuild the acting view's task when it runs under another profile", async () => { + // The acting view pins the saved profile while its own task follows a + // different sticky profile: the non-activating save refreshes the view + // overlay but must leave the task's handler alone. + const task = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "other-key" }, + taskApiConfigName: "other-profile", + updateApiConfiguration: vi.fn(), + } + vi.spyOn(provider, "getCurrentTask").mockReturnValue( + task as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + provider["viewLocalState"].currentApiConfigName = "saved-profile" + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("saved-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + + await provider.upsertProviderProfile( + "saved-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" }, + false, + ) + + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) + expect(task.updateApiConfiguration).not.toHaveBeenCalled() + }) it("does not leak shared provider keys into a pinned view's api configuration", async () => { vi.spyOn(console, "log").mockImplementation(() => {}) vi.spyOn(console, "warn").mockImplementation(() => {}) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index b6e4a7019a..247a6bfc07 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -1579,10 +1579,10 @@ describe("ClineProvider - Sticky Mode", () => { it("does not resolve a view id that names an Object.prototype key through the chain", async () => { await provider.resolveWebviewView(mockWebviewView) - mockContext.globalState.get = vi.fn().mockImplementation((key: string) => { - if (key === "viewStates") return { "stable-test-view": { mode: "architect" } } - return undefined - }) + // Seed through the durable write path so later writes land in the same + // backing map the assertions read: a frozen get() literal would make every + // post-write assertion read the seed no matter what the provider wrote. + await provider.contextProxy.setValue("viewStates", { "stable-test-view": { mode: "architect" } }) const states = provider["getPersistedViewStates"]({ fresh: true }) expect(Object.getPrototypeOf(states)).toBeNull() @@ -1673,10 +1673,10 @@ describe("ClineProvider - Sticky Mode", () => { // Temporary ids restart from 0 on every host start, so the same name can // belong to a different session. An entry this instance never wrote must not // be adopted as this view's state. - mockContext.globalState.get = vi.fn().mockImplementation((key: string) => { - if (key === "viewStates") return { [provider.viewId]: { mode: "architect" } } - return undefined - }) + // Seed through the durable write path so the assertions below read the + // same backing map a re-key adoption would write to: a frozen get() + // literal made the negative assertion unable to fail. + await provider.contextProxy.setValue("viewStates", { [provider.viewId]: { mode: "architect" } }) await provider["setViewStateId"]("stable-test-view") From 66f594521bd889e6d3cbb27ad1278a4dad27a83a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 22:45:15 +0800 Subject: [PATCH 34/34] fix(webview): gate deleted-profile re-pin task writes on the task's own profile Port of the sibling-unit fix: a view pinned to a deleted profile can hold a task that follows a different sticky profile: the deletion re-pin must re-point the view buffer but must not force the replacement's settings onto that conversation's handler, nor rewrite its persisted sticky profile name. Both task-side writes are now gated on a taskApiConfigName match with the deleted profile, mirroring the profile-save rebuild guards. Regression test included. --- src/core/webview/ClineProvider.ts | 14 +++- .../webview/__tests__/ClineProvider.spec.ts | 83 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c9c4586b9a..be738b4b7c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2734,11 +2734,17 @@ export class ClineProvider // the handler with the replacement's settings and re-point the sticky name, // or the running conversation keeps sending the deleted profile's // configuration even though the view's buffer was re-pointed (the posted - // state prefers the task's own apiConfiguration). - if (replacementSettings) { - instance.updateTaskApiHandlerIfNeeded(replacementSettings, { forceRebuild: true }) + // state prefers the task's own apiConfiguration). Only re-point when the + // task actually runs under the deleted profile: a view pinned to it can + // hold a task that follows a different sticky profile, and forcing the + // replacement would silently switch that conversation and rewrite its + // persisted profile name without the user asking. + if (instance.getCurrentTask()?.taskApiConfigName === deletedProfileName) { + if (replacementSettings) { + instance.updateTaskApiHandlerIfNeeded(replacementSettings, { forceRebuild: true }) + } + await instance.persistStickyProviderProfileToCurrentTask(replacementName) } - await instance.persistStickyProviderProfileToCurrentTask(replacementName) await instance.postStateToWebview() }), ) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 2b48c9c6c3..34f6978b1c 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2940,6 +2940,89 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("does not re-point a pinned sibling's task that follows another profile when its profile is deleted", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + // The sibling pins the profile being deleted, but its active task + // follows a different sticky profile: the deletion must re-point the + // sibling's buffer to the surviving profile without switching that + // conversation's handler or rewriting its persisted profile name. + sibling["viewLocalState"].currentApiConfigName = "old-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "deleted-profile-secret", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "other-key" }, + taskApiConfigName: "other-profile", + taskId: "sibling-task", + setTaskApiConfigName: vi.fn(), + updateApiConfiguration: vi.fn(), + } + vi.spyOn(sibling, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // The sibling's view buffer is still re-pointed to the surviving + // profile... + expect(sibling["viewLocalState"].currentApiConfigName).toBe("keeper-profile") + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }) + // ...but the task follows another profile: neither the handler rebuild + // nor the sticky-name rewrite may touch it. + expect(siblingTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(siblingTask.setTaskApiConfigName).not.toHaveBeenCalled() + // Let queued view-local persistence settle while the console spies are + // still installed, so teardown has no pending writes. + await new Promise((resolve) => setTimeout(resolve, 50)) + } finally { + await sibling.dispose() + await provider.dispose() + } + }) + it("leaves the view buffer untouched when the deleted profile is neither globally active nor view-pinned", async () => { const provider = new ClineProvider( mockContext,