From 4c3e1db1aaa5230518680eddc50309c3f1297218 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 8 Aug 2026 00:01:31 +0800 Subject: [PATCH] feat(webview): add viewStateId generation and persistence infrastructure - Export VSCodeAPIWrapper class and add getViewStateId() method - Generate unique viewStateId using crypto.randomUUID() with fallback - Persist viewStateId in localStorage for dev server compatibility - Add viewStateSchema to global-settings.ts for type safety - Send viewStateId during webviewDidLaunch handshake --- packages/types/src/__tests__/index.test.ts | 5 + packages/types/src/global-settings.ts | 10 + src/core/webview/ClineProvider.ts | 511 ++++++++++++++---- src/eslint-suppressions.json | 5 - .../src/context/ExtensionStateContext.tsx | 5 +- webview-ui/src/utils/__tests__/vscode.spec.ts | 89 +++ webview-ui/src/utils/vscode.ts | 58 +- 7 files changed, 559 insertions(+), 124 deletions(-) create mode 100644 webview-ui/src/utils/__tests__/vscode.spec.ts 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 dc3ea072fd..d117d7bd84 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -99,6 +99,15 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * Persisted non-secret selections for a stable webview instance. + */ +export const viewStateSchema = z.object({ + mode: z.string().optional(), + currentApiConfigName: z.string().optional(), + updatedAt: z.number().optional(), +}) + /** * GlobalSettings */ @@ -107,6 +116,7 @@ export const globalSettingsSchema = z.object({ currentApiConfigName: z.string().optional(), listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(), pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(), + viewStates: z.record(z.string(), viewStateSchema).optional(), lastShownAnnouncementId: z.string().optional(), customInstructions: z.string().optional(), diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 206d6ca611..d3f9f2d977 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -52,6 +52,7 @@ import { getModelId, isRetiredProvider, providerIdentifiers, + PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -112,6 +113,7 @@ import { saveTaskMessages, TaskHistoryStore, assertValidTransition, + ApiMessage, } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" @@ -120,6 +122,8 @@ import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" import { PendingEditOperationStore, type PendingEditOperationInput } from "./PendingEditOperationStore" +type PersistedViewState = NonNullable[string] + /** * 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 @@ -171,6 +175,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 view?: vscode.WebviewView | vscode.WebviewPanel @@ -270,6 +277,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 = "aug-2026-v3.76.0-dcg-providers-terminal" // v3.76.0 destructive command guard, provider improvements, and terminal execution fix @@ -284,14 +310,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) @@ -322,6 +351,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) => { @@ -472,6 +504,138 @@ export class ClineProvider } } + 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 } + } + + private async savePersistedViewState(values: Partial): Promise { + 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 + } + + 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 + } + + 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), + ) + } + + public async setViewStateId(viewStateId: string | undefined): Promise { + const normalizedViewStateId = viewStateId?.trim() + + if (!normalizedViewStateId || normalizedViewStateId === this.viewStateId) { + return + } + + this.viewStateId = normalizedViewStateId.replace(/[^A-Za-z0-9_-]/g, "_") + 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 { + try { + const persisted = this.getPersistedViewStates()[this.viewStateId] + const loadedState: Partial = {} + + if (persisted?.mode) { + loadedState.mode = persisted.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)}`, + ) + } + } + + 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)}`, + ) + } + } + + /** + * Save a single view-local state value. Only non-secret selections are persisted durably. + */ + private async saveViewState(key: keyof ExtensionState, value: unknown): Promise { + try { + await this._saveViewLocalStateFromMutation({ [key]: value } as Partial & + Partial) + + this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`) + } catch (error) { + this.log( + `[saveViewState] Error saving state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -479,7 +643,8 @@ export class ClineProvider event: K, listener: (...args: TaskProviderEvents[K]) => void | Promise, ): this { - return super.on(event, listener as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (super.on as any)(event, listener) } /** @@ -489,7 +654,8 @@ export class ClineProvider event: K, listener: (...args: TaskProviderEvents[K]) => void | Promise, ): this { - return super.off(event, listener as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (super.off as any)(event, listener) } /** @@ -852,7 +1018,7 @@ export class ClineProvider public static async handleCodeAction( command: CodeActionId, promptType: CodeActionName, - params: Record, + params: Record, ): Promise { // Capture telemetry for code action usage TelemetryService.instance.captureCodeActionUsed(promptType) @@ -884,7 +1050,7 @@ export class ClineProvider public static async handleTerminalAction( command: TerminalActionId, promptType: TerminalActionPromptType, - params: Record, + params: Record, ): Promise { TelemetryService.instance.captureCodeActionUsed(promptType) @@ -1146,6 +1312,7 @@ export class ClineProvider } await this.updateGlobalState("mode", historyItem.mode) + this.viewLocalState.mode = historyItem.mode // Load the saved API config for the restored mode if it exists. // Skip mode-based profile activation if historyItem.apiConfigName exists, @@ -1594,7 +1761,7 @@ export class ClineProvider } // Only update the task's mode after successful persistence. - ;(task as any)._taskMode = newMode + ;(task as unknown as Record)._taskMode = newMode } catch (error) { // If persistence fails, log the error but don't update the in-memory state. this.log( @@ -1607,7 +1774,7 @@ export class ClineProvider } } - await this.updateGlobalState("mode", newMode) + await this.saveViewState("mode", newMode) this.emit(RooCodeEventName.ModeChanged, newMode) @@ -1712,7 +1879,7 @@ export class ClineProvider task.updateApiConfiguration(providerSettings) } else { // No rebuild needed, just sync apiConfiguration - ;(task as any).apiConfiguration = providerSettings + ;(task as Task).apiConfiguration = providerSettings } } @@ -1757,13 +1924,23 @@ export class ClineProvider // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) // We should probably switch to that and verify that it works. // I left the original implementation in just to be safe. + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + await Promise.all([ - this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + this.updateGlobalState("listApiConfigMeta", listApiConfigMeta), this.updateGlobalState("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), + this.saveViewState("currentApiConfigName", name), + this.saveViewState("apiConfiguration", providerSettings), ]) + await this._saveViewLocalStateFromMutation({ + listApiConfigMeta, + currentApiConfigName: name, + apiConfiguration: providerSettings, + }) + // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) @@ -1807,6 +1984,11 @@ export class ClineProvider listApiConfigMeta: entries, }) + this._updateViewLocalStateFromMutation({ + currentApiConfigName: profileToActivate, + listApiConfigMeta: entries, + }) + await this.postStateToWebview() } @@ -1874,11 +2056,21 @@ export class ClineProvider if (!skipCurrentTaskRebuild) { // See `upsertProviderProfile` for a description of what this is doing. + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + await Promise.all([ - this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + this.contextProxy.setValue("listApiConfigMeta", listApiConfigMeta), this.contextProxy.setValue("currentApiConfigName", name), this.contextProxy.setProviderSettings(providerSettings), + this.saveViewState("currentApiConfigName", name), + this.saveViewState("apiConfiguration", providerSettings), ]) + + await this._saveViewLocalStateFromMutation({ + listApiConfigMeta, + currentApiConfigName: name, + apiConfiguration: providerSettings, + }) } const { mode } = await this.getState() @@ -2731,12 +2923,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 as ProviderName) : "anthropic" // Build the apiConfiguration object combining state values and secrets. @@ -2798,117 +2996,120 @@ 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, - alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, - alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, - alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, - 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, + alwaysAllowWrite: mergedStateValues.alwaysAllowWrite ?? false, + alwaysAllowWriteOutsideWorkspace: mergedStateValues.alwaysAllowWriteOutsideWorkspace ?? false, + alwaysAllowWriteProtected: mergedStateValues.alwaysAllowWriteProtected ?? false, + alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, - 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, + 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: 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 ?? "openai", - codebaseIndexEmbedderBaseUrl: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", - codebaseIndexEmbedderModelId: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? "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, } } @@ -3011,6 +3212,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) { @@ -3018,11 +3220,94 @@ 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) + await this._saveViewLocalStateFromMutation(values) + } + + 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, + } + } + } + + 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 @@ -3051,6 +3336,10 @@ export class ClineProvider } await this.contextProxy.resetAllState() + + // Clear view-local state cache so getState() falls back to ContextProxy defaults. + this._clearViewLocalState() + await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() @@ -3714,7 +4003,7 @@ export class ClineProvider // The mode switch must happen before createTask() because the Task constructor // initializes its mode from provider.getState() during initializeTaskMode(). try { - await this.handleModeSwitch(mode as any) + await this.handleModeSwitch(mode as Mode) } catch (e) { this.log( `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ @@ -3734,7 +4023,7 @@ export class ClineProvider // Without this, the child's fire-and-forget startTask() races with step 5, // and the last writer to globalState overwrites the other's changes— // causing the parent's delegation fields to be lost. - const child = await this.createTask(message, undefined, parent as any, { + const child = await this.createTask(message, undefined, parent as Task | undefined, { initialTodos, initialStatus: "active", startTask: false, @@ -3891,12 +4180,12 @@ export class ClineProvider parentClineMessages = [] } - let parentApiMessages: any[] = [] + let parentApiMessages: ApiMessage[] = [] try { - parentApiMessages = (await readApiMessages({ + parentApiMessages = await readApiMessages({ taskId: parentTaskId, globalStoragePath, - })) as any[] + }) } catch { parentApiMessages = [] } @@ -4007,7 +4296,7 @@ export class ClineProvider } } - await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath }) + await saveApiMessages({ messages: parentApiMessages, taskId: parentTaskId, globalStoragePath }) // 4) Close child instance if still open (single-open-task invariant). // This MUST happen BEFORE marking the child "completed" because @@ -4082,7 +4371,7 @@ export class ClineProvider // non-fatal } try { - await parentInstance.overwriteApiConversationHistory(parentApiMessages as any) + await parentInstance.overwriteApiConversationHistory(parentApiMessages) } catch { // non-fatal } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 53d5ba4441..435ef92409 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1049,11 +1049,6 @@ "count": 2 } }, - "core/webview/ClineProvider.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 12 - } - }, "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 34 diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index a672abbdc1..61c2f6a0d8 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -511,7 +511,10 @@ export const ExtensionStateContextProvider: React.FC<{ }, [handleMessage]) useEffect(() => { - vscode.postMessage({ type: "webviewDidLaunch" }) + vscode.postMessage({ + type: "webviewDidLaunch", + viewStateId: typeof vscode.getViewStateId === "function" ? vscode.getViewStateId() : undefined, + }) }, []) // Apply the configurable chat font size as a CSS variable. When unset, the diff --git a/webview-ui/src/utils/__tests__/vscode.spec.ts b/webview-ui/src/utils/__tests__/vscode.spec.ts new file mode 100644 index 0000000000..70cc10c0e6 --- /dev/null +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -0,0 +1,89 @@ +import { VSCodeAPIWrapper } from "../vscode" + +const originalCrypto = globalThis.crypto +const originalLocalStorage = globalThis.localStorage + +const createMockStorage = (initialState: Record = {}) => { + const state = { ...initialState } + return { + getItem: vi.fn((key: string) => state[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + state[key] = value + }), + removeItem: vi.fn((key: string) => { + delete state[key] + }), + clear: vi.fn(() => { + for (const key of Object.keys(state)) { + delete state[key] + } + }), + } as unknown as Storage +} + +describe("VSCodeAPIWrapper", () => { + afterEach(() => { + vi.restoreAllMocks() + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: originalCrypto, + }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: originalLocalStorage, + }) + }) + + it("reuses the persisted webview viewStateId when browser storage is available", () => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "persisted-view" }) }), + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("persisted-view") + }) + + it("creates and persists a new viewStateId when storage has been cleared", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "generated-view") }, + }) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("generated-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "generated-view" }) + }) + + it("falls back to in-memory state when browser storage access is restricted", () => { + const randomUUID = vi.fn().mockReturnValueOnce("memory-view").mockReturnValueOnce("new-memory-view") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + const storage = { + getItem: vi.fn(() => { + throw new Error("storage denied") + }), + setItem: vi.fn(() => { + throw new Error("storage denied") + }), + } as unknown as Storage + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("memory-view") + expect(wrapper.getViewStateId()).toBe("memory-view") + expect(randomUUID).toHaveBeenCalledTimes(1) + expect(storage.getItem).toHaveBeenCalled() + expect(storage.setItem).toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index 2cc0a58909..fe7940d142 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -11,8 +11,9 @@ import { WebviewMessage } from "@roo/WebviewMessage" * dev server by using native web browser features that mock the functionality * enabled by acquireVsCodeApi. */ -class VSCodeAPIWrapper { +export class VSCodeAPIWrapper { private readonly vsCodeApi: WebviewApi | undefined + private fallbackState: unknown | undefined constructor() { // Check if the acquireVsCodeApi function exists in the current development @@ -22,6 +23,31 @@ class VSCodeAPIWrapper { } } + private createViewStateId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID() + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + } + + public getViewStateId(): string { + const currentState = this.getState() + const stateObject = + currentState && typeof currentState === "object" && !Array.isArray(currentState) + ? (currentState as Record) + : {} + const existingViewStateId = stateObject.viewStateId + + if (typeof existingViewStateId === "string" && existingViewStateId.length > 0) { + return existingViewStateId + } + + const viewStateId = this.createViewStateId() + this.setState({ ...stateObject, viewStateId }) + return viewStateId + } + /** * Post a message (i.e. send arbitrary data) to the owner of the webview. * @@ -49,10 +75,18 @@ class VSCodeAPIWrapper { public getState(): unknown | undefined { if (this.vsCodeApi) { return this.vsCodeApi.getState() - } else { - const state = localStorage.getItem("vscodeState") - return state ? JSON.parse(state) : undefined } + + try { + if (typeof localStorage?.getItem === "function") { + const state = localStorage.getItem("vscodeState") + return state ? JSON.parse(state) : this.fallbackState + } + } catch { + return this.fallbackState + } + + return this.fallbackState } /** @@ -69,10 +103,20 @@ class VSCodeAPIWrapper { public setState(newState: T): T { if (this.vsCodeApi) { return this.vsCodeApi.setState(newState) - } else { - localStorage.setItem("vscodeState", JSON.stringify(newState)) - return newState } + + this.fallbackState = newState + + try { + if (typeof localStorage?.setItem === "function") { + localStorage.setItem("vscodeState", JSON.stringify(newState)) + } + } catch { + // Storage can be unavailable in restricted webview/browser contexts. + // The in-memory fallback above keeps a stable viewStateId for this session. + } + + return newState } }