diff --git a/packages/types/src/__tests__/global-settings.test.ts b/packages/types/src/__tests__/global-settings.test.ts index c2040383e8..b4ffaa8e77 100644 --- a/packages/types/src/__tests__/global-settings.test.ts +++ b/packages/types/src/__tests__/global-settings.test.ts @@ -5,6 +5,28 @@ import { globalSettingsSchema, } from "../global-settings.js" +describe("chat display global settings", () => { + it.each(["marquee", "breathing"])("accepts chatInputEffect %s", (chatInputEffect) => { + expect(globalSettingsSchema.parse({ chatInputEffect })).toEqual({ chatInputEffect }) + }) + + it.each([true, false])("accepts tableStriped %s", (tableStriped) => { + expect(globalSettingsSchema.parse({ tableStriped })).toEqual({ tableStriped }) + }) + + it("allows both settings to be omitted", () => { + expect(globalSettingsSchema.parse({})).toEqual({}) + }) + + it.each(["none", true, 1, null])("rejects invalid chatInputEffect %s", (chatInputEffect) => { + expect(() => globalSettingsSchema.parse({ chatInputEffect })).toThrow() + }) + + it.each(["true", "false", 0, 1, null])("rejects non-boolean tableStriped %s", (tableStriped) => { + expect(() => globalSettingsSchema.parse({ tableStriped })).toThrow() + }) +}) + describe("destructive command guard global setting", () => { it("is opt-in by default", () => { expect(DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED).toBe(false) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 16e9751974..38755e268d 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -283,6 +283,19 @@ export const globalSettingsSchema = z.object({ * @default "send" */ enterBehavior: z.enum(["send", "newline"]).optional(), + /** + * Controls the border effect on the chat input box while AI is working. + * - "marquee": A conic-gradient light beam orbits the ring (default) + * - "breathing": A pulsing glow + 1px blue border ring + * @default "marquee" + */ + chatInputEffect: z.enum(["marquee", "breathing"]).optional(), + /** + * Opt-in zebra striping (alternating background colors) for rows in + * markdown tables rendered in the chat. + * @default false + */ + tableStriped: z.boolean().optional(), profileThresholds: z.record(z.string(), z.number()).optional(), hasOpenedModeSelector: z.boolean().optional(), lastModeExportPath: z.string().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c0e8509105..d79a0a49c0 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -29,6 +29,7 @@ import type { WorktreeIncludeStatus } from "./worktree.js" */ export interface ExtensionMessage { type: + | "settingsSaveResult" | "action" | "state" | "taskHistoryUpdated" @@ -157,6 +158,7 @@ export interface ExtensionMessage { /** Generic payload for extension messages that use `values` */ // eslint-disable-next-line @typescript-eslint/no-explicit-any values?: Record + unsavedSettings?: string[] requestId?: string themeFixture?: WebviewThemeFixture promptText?: string @@ -328,6 +330,8 @@ export type ExtensionState = Pick< | "reasoningBlockCollapsed" | "chatFontSize" | "enterBehavior" + | "chatInputEffect" + | "tableStriped" | "includeCurrentTime" | "includeCurrentCost" | "maxGitStatusFiles" diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 97d4104afc..7426cb5cdc 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -360,13 +360,13 @@ export class ContextProxy { return value !== undefined ? value : defaultValue } - updateGlobalState(key: K, value: GlobalState[K]) { + async updateGlobalState(key: K, value: GlobalState[K]) { if (isPassThroughStateKey(key)) { return this.originalContext.globalState.update(key, value) } + await this.originalContext.globalState.update(key, value) this.stateCache[key] = value - return this.originalContext.globalState.update(key, value) } private getAllGlobalState(): GlobalState { @@ -382,14 +382,11 @@ export class ContextProxy { return this.secretCache[key] } - storeSecret(key: SecretStateKey, value?: string) { - // Update cache. - this.secretCache[key] = value - - // Write directly to context. - return value === undefined + async storeSecret(key: SecretStateKey, value?: string) { + await (value === undefined ? this.originalContext.secrets.delete(key) - : this.originalContext.secrets.store(key, value) + : this.originalContext.secrets.store(key, value)) + this.secretCache[key] = value } /** diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 3fcc0e6e43..ce61b2026b 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -450,6 +450,7 @@ export class ProviderSettingsManager { */ public async activateProfile( params: { name: string } | { id: string }, + mode?: Mode, ): Promise { const { name, ...providerSettings } = await this.getProfile(params) @@ -457,6 +458,12 @@ export class ProviderSettingsManager { return await this.lock(async () => { const providerProfiles = await this.load() providerProfiles.currentApiConfigName = name + if (mode && providerSettings.id) { + providerProfiles.modeApiConfigs = { + ...providerProfiles.modeApiConfigs, + [mode]: providerSettings.id, + } + } await this.store(providerProfiles) return { name, ...providerSettings } }) diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 2319a6b1a5..9fc674c59d 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -157,6 +157,13 @@ describe("ContextProxy", () => { }) describe("updateGlobalState", () => { + it("retains the saved cache value when persistence fails", async () => { + await proxy.updateGlobalState("chatInputEffect", "marquee") + mockGlobalState.update.mockRejectedValueOnce(new Error("disk full")) + await expect(proxy.updateGlobalState("chatInputEffect", "breathing")).rejects.toThrow("disk full") + expect(proxy.getGlobalState("chatInputEffect")).toBe("marquee") + }) + it("should update state directly in original context", async () => { await proxy.updateGlobalState("apiProvider", "deepseek") @@ -208,6 +215,22 @@ describe("ContextProxy", () => { }) describe("storeSecret", () => { + it("retains the saved secret when deletion fails", async () => { + await proxy.storeSecret("apiKey", "saved-secret") + mockSecrets.delete.mockRejectedValueOnce(new Error("storage unavailable")) + + await expect(proxy.storeSecret("apiKey", undefined)).rejects.toThrow("storage unavailable") + expect(mockSecrets.delete).toHaveBeenCalledWith("apiKey") + expect(proxy.getSecret("apiKey")).toBe("saved-secret") + }) + + it("retains the saved secret when persistence fails", async () => { + await proxy.storeSecret("apiKey", "saved-secret") + mockSecrets.store.mockRejectedValueOnce(new Error("storage unavailable")) + await expect(proxy.storeSecret("apiKey", "unsaved-secret")).rejects.toThrow("storage unavailable") + expect(proxy.getSecret("apiKey")).toBe("saved-secret") + }) + it("should store secret directly in original context", async () => { await proxy.storeSecret("apiKey", "new-secret") diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 13e1aeeb2d..f2e574f183 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -769,6 +769,51 @@ describe("ProviderSettingsManager", () => { }) describe("LoadConfig", () => { + it.each([ + { + scenario: "updates only the requested mode for an identified profile", + id: "test-id", + mode: "code", + expectedMappings: { code: "test-id", ask: "other-id" }, + }, + { + scenario: "preserves mode mappings when no mode is supplied", + id: "test-id", + mode: undefined, + expectedMappings: { code: "previous-id", ask: "other-id" }, + }, + { + scenario: "activates a profile without an ID without creating a mode mapping", + id: undefined, + mode: "architect", + expectedMappings: { code: "previous-id", ask: "other-id" }, + }, + ])("$scenario", async ({ id, mode, expectedMappings }) => { + // Finish constructor migrations before loading a valid profile without an ID. + await providerSettingsManager.initialize() + const profile = { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", id } + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { test: profile }, + modeApiConfigs: { code: "previous-id", ask: "other-id" }, + } + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + mockSecrets.store.mockClear() + + await expect(providerSettingsManager.activateProfile({ name: "test" }, mode)).resolves.toEqual({ + name: "test", + ...profile, + }) + + expect(mockSecrets.store).toHaveBeenCalledOnce() + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(storedConfig).toEqual({ + ...existingConfig, + currentApiConfigName: "test", + modeApiConfigs: expectedMappings, + }) + }) + it("should load config and update current config name", async () => { const existingConfig: ProviderProfiles = { currentApiConfigName: "default", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 60a92eebaa..3115f89146 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -18,6 +18,9 @@ import { type ProviderName, type ProviderSettings, type RooCodeSettings, + PROVIDER_SETTINGS_KEYS, + providerSettingsSchema, + isSecretStateKey, type ProviderSettingsEntry, type StaticAppProperties, type DynamicAppProperties, @@ -230,6 +233,11 @@ export class ClineProvider private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined private _disposed = false + private settingsSaveController = new AbortController() + + public get settingsSaveSignal(): AbortSignal { + return this.settingsSaveController.signal + } private readonly _postStateToWebviewThrottled = debounce( async () => { try { @@ -258,25 +266,40 @@ export class ClineProvider return runDelegationTransition(ClineProvider.delegationTransitionLocks, parentTaskId, fn) } - private enqueueProviderProfileMutation(fn: (signal: AbortSignal) => Promise): Promise { + private enqueueProviderProfileMutation( + fn: (signal: AbortSignal) => Promise, + { allowTimeout = true }: { allowTimeout?: boolean } = {}, + ): Promise { const controller = new AbortController() - // Run fn after either outcome so a rejected mutation never poisons the queue. - const run = this.providerProfileMutationQueue.then( - () => fn(controller.signal), - () => fn(controller.signal), - ) - const callerResult = this.withProviderProfileMutationTimeout(run, () => { - controller.abort() - this.log("Provider profile mutation timed out; aborting in-flight mutation") - }) + // A caller can time out while still queued; never start its mutation afterwards. + const runMutation = () => { + controller.signal.throwIfAborted() + return fn(controller.signal) + } + const previous = this.providerProfileMutationQueue + const run = previous.then(runMutation, runMutation) + // A profile save that has started persisting must finish before another mutation runs. + const slowMutationTimer = allowTimeout + ? undefined + : setTimeout(() => { + this.log("Provider profile save is still pending; waiting for persistence to finish") + }, ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + const callerResult = allowTimeout + ? this.withProviderProfileMutationTimeout(run, () => { + controller.abort() + this.log("Provider profile mutation timed out; aborting in-flight mutation") + }) + : run void run.then( () => { + clearTimeout(slowMutationTimer) if (controller.signal.aborted) { this.log("Provider profile mutation completed after cancellation") } }, (error) => { + clearTimeout(slowMutationTimer) if (controller.signal.aborted) { this.log( `Provider profile mutation errored after cancellation: ${ @@ -287,9 +310,8 @@ export class ClineProvider }, ) - // Advance from the timeout-bounded result. Each fn checks its AbortSignal before - // writing state, so advancing the queue on timeout cannot produce stale overwrites. - this.providerProfileMutationQueue = callerResult.then( + // Caller cancellation must not release the lock while the mutation is still running. + this.providerProfileMutationQueue = run.then( () => undefined, () => undefined, ) @@ -814,6 +836,7 @@ export class ClineProvider - https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts */ private clearWebviewResources() { + this.settingsSaveController.abort() this.rejectPendingThemeFixtureProbes(new Error("Webview was disposed before the theme fixture probe completed")) while (this.webviewDisposables.length) { const x = this.webviewDisposables.pop() @@ -840,6 +863,7 @@ export class ClineProvider } this._disposed = true + this.settingsSaveController.abort() this._postStateToWebviewThrottled.cancel() this.log("Disposing ClineProvider...") @@ -1016,6 +1040,10 @@ export class ClineProvider } async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) { + if (this._disposed) return + if (this.settingsSaveController.signal.aborted) { + this.settingsSaveController = new AbortController() + } this.view = webviewView const inTabMode = "onDidChangeViewState" in webviewView @@ -1699,8 +1727,14 @@ export class ClineProvider * @param webview A reference to the extension webview */ private setWebviewMessageListener(webview: vscode.Webview) { - const onReceiveMessage = async (message: WebviewMessage) => - webviewMessageHandler(this, message, this.marketplaceManager) + const onReceiveMessage = async (message: WebviewMessage) => { + const signal = this.settingsSaveSignal + try { + await webviewMessageHandler(this, message, this.marketplaceManager) + } catch (error) { + if (!signal.aborted || error !== signal.reason) throw error + } + } const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage) this.webviewDisposables.push(messageDisposable) @@ -1723,6 +1757,8 @@ export class ClineProvider targetTask: Task | null | undefined, signal?: AbortSignal, ): Promise { + if (signal?.aborted) return + const task = targetTask if (task) { @@ -1737,7 +1773,8 @@ export class ClineProvider await this.updateTaskHistory({ ...taskHistoryItem, mode: newMode }) } - // Only update the task's mode after successful persistence. + // Once history is committed, finish syncing the live and global modes even + // after timeout. The queue stays locked until this persistence settles. ;(task as any)._taskMode = newMode } catch (error) { // If persistence fails, log the error but don't update the in-memory state. @@ -1753,6 +1790,8 @@ export class ClineProvider await this.updateGlobalState("mode", newMode) + if (signal?.aborted) return + this.emit(RooCodeEventName.ModeChanged, newMode) // If workspace lock is on, keep the current API config — don't load mode-specific config @@ -1768,6 +1807,8 @@ export class ClineProvider // Load the saved API config for the new mode if it exists. const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) + if (signal?.aborted) return + const listApiConfig = await this.providerSettingsManager.listConfig() if (signal?.aborted) return @@ -1775,6 +1816,8 @@ export class ClineProvider // Update listApiConfigMeta first to ensure UI has latest data. await this.updateGlobalState("listApiConfigMeta", listApiConfig) + if (signal?.aborted) return + // If this mode has a saved config, use it. if (savedConfigId) { const profile = listApiConfig.find(({ id }) => id === savedConfigId) @@ -1787,6 +1830,8 @@ export class ClineProvider // Skip activation if the profile has no apiProvider set - this indicates // an unconfigured/empty profile. const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name }) + if (signal?.aborted) return + const hasActualSettings = !!fullProfile.apiProvider if (hasActualSettings) { @@ -1814,6 +1859,8 @@ export class ClineProvider } } + if (signal?.aborted) return + if (targetTask !== null) { await this.postStateToWebview() } @@ -1876,52 +1923,66 @@ export class ClineProvider name: string, providerSettings: ProviderSettings, activate: boolean = true, + saveSignal?: AbortSignal, ): Promise { try { - return await this.enqueueProviderProfileMutation(async (signal) => { - // TODO: Do we need to be calling `activateProfile`? It's not - // clear to me what the source of truth should be; in some cases - // we rely on the `ContextProxy`'s data store and in other cases - // we rely on the `ProviderSettingsManager`'s data store. It might - // be simpler to unify these two. - const id = await this.providerSettingsManager.saveConfig(name, providerSettings) - - if (signal.aborted) return id - - if (activate) { - const { mode } = await this.getState() - - // These promises do the following: - // 1. Adds or updates the list of provider profiles. - // 2. Sets the current provider profile. - // 3. Sets the current mode's provider profile. - // 4. Copies the provider settings to the context. - // - // Note: 1, 2, and 4 can be done in one `ContextProxy` call: - // 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. - await Promise.all([ - this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.updateGlobalState("currentApiConfigName", name), - this.providerSettingsManager.setModeConfig(mode, id), - this.contextProxy.setProviderSettings(providerSettings), - ]) - - // Change the provider for the current task. - // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) - - // Keep the current task's sticky provider profile in sync with the newly-activated profile. - await this.persistStickyProviderProfileToCurrentTask(name) - } else { - await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) - } + return await this.enqueueProviderProfileMutation( + async (mutationSignal) => { + const signal = saveSignal ? AbortSignal.any([mutationSignal, saveSignal]) : mutationSignal + signal.throwIfAborted() + // TODO: Do we need to be calling `activateProfile`? It's not + // clear to me what the source of truth should be; in some cases + // we rely on the `ContextProxy`'s data store and in other cases + // we rely on the `ProviderSettingsManager`'s data store. It might + // be simpler to unify these two. + const id = await this.providerSettingsManager.saveConfig(name, providerSettings) + + // Once saveConfig starts, disposal cannot cancel the remaining persistence. + // Keep provider storage, cached metadata, and active settings synchronized; + // the settings queue retains the write lock even if its caller is cancelled. + + if (activate) { + const { mode } = await this.getState() + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + + // These promises do the following: + // 1. Adds or updates the list of provider profiles. + // 2. Sets the current provider profile. + // 3. Sets the current mode's provider profile. + // 4. Copies the provider settings to the context. + // + // Note: 1, 2, and 4 can be done in one `ContextProxy` call: + // 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 results = await Promise.allSettled([ + this.updateGlobalState("listApiConfigMeta", listApiConfigMeta), + this.updateGlobalState("currentApiConfigName", name), + this.providerSettingsManager.setModeConfig(mode, id), + this.contextProxy.setProviderSettings(providerSettings), + ]) + const failure = results.find((result) => result.status === "rejected") + if (failure?.status === "rejected") throw failure.reason + + // Change the provider for the current task. + // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). + this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + + // Keep the current task's sticky provider profile in sync with the newly-activated profile. + await this.persistStickyProviderProfileToCurrentTask(name) + } else { + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + await this.updateGlobalState("listApiConfigMeta", listApiConfigMeta) + } - await this.postStateToWebview() - return id - }) + signal.throwIfAborted() + await this.postStateToWebview() + return id + }, + { allowTimeout: false }, + ) } catch (error) { + saveSignal?.throwIfAborted() this.log( `Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) @@ -1984,6 +2045,61 @@ export class ClineProvider } } + /** Called inside the launch settings-save queue; finish or roll back once writes begin. */ + async repairProviderProfile(name: string, metadata: ProviderSettingsEntry[], signal: AbortSignal): Promise { + await this.enqueueProviderProfileMutation( + async () => { + const { + name: profileName, + id: _id, + ...settings + } = await this.providerSettingsManager.getProfile({ name }) + const { mode } = await this.getState() + signal.throwIfAborted() + const values: RooCodeSettings = { + ...providerSettingsSchema.parse(settings), + listApiConfigMeta: metadata, + currentApiConfigName: profileName, + } + const previous = this.contextProxy.getValues() + // Match setProviderSettings: clear absent global settings, retaining shared secrets. + const keys: (keyof RooCodeSettings)[] = [ + ...PROVIDER_SETTINGS_KEYS.filter( + (key) => Object.hasOwn(values, key) || (!isSecretStateKey(key) && previous[key] !== undefined), + ), + "listApiConfigMeta", + "currentApiConfigName", + ] + const attempted: (keyof RooCodeSettings)[] = [] + try { + // Sequential writes let rollback wait for every attempted write to settle. + for (const key of keys) { + attempted.push(key) + await this.contextProxy.setValue(key, values[key]) + } + // Commit the active name and mode mapping in one profile-store write, last. + await this.providerSettingsManager.activateProfile({ name: profileName }, mode) + } catch (error) { + const unsaved: (keyof RooCodeSettings)[] = [] + for (const key of attempted.reverse()) { + try { + await this.contextProxy.setValue(key, previous[key]) + } catch { + unsaved.push(key) + } + } + if (unsaved.length) { + this.log( + `Provider profile repair rollback failed; unsaved fields (retry required): ${unsaved.join(", ")}`, + ) + } + throw error + } + }, + { allowTimeout: false }, + ) + } + async activateProviderProfile( args: { name: string } | { id: string }, options?: { @@ -2006,6 +2122,8 @@ export class ClineProvider }, signal?: AbortSignal, ): Promise { + if (signal?.aborted) return + const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) if (signal?.aborted) return @@ -2016,19 +2134,30 @@ export class ClineProvider if (!skipCurrentTaskRebuild) { // See `upsertProviderProfile` for a description of what this is doing. - await Promise.all([ - this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + if (signal?.aborted) return + + // Hold the queue until all started writes settle, including on failure. + const results = await Promise.allSettled([ + this.contextProxy.setValue("listApiConfigMeta", listApiConfigMeta), this.contextProxy.setValue("currentApiConfigName", name), this.contextProxy.setProviderSettings(providerSettings), ]) + const failure = results.find((result) => result.status === "rejected") + if (failure?.status === "rejected") throw failure.reason + if (signal?.aborted) return } const { mode } = await this.getState() + if (signal?.aborted) return + if (id && persistModeConfig) { await this.providerSettingsManager.setModeConfig(mode, id) } + if (signal?.aborted) return + // Change the provider for the current task. this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true, skipCurrentTaskRebuild }) @@ -2038,10 +2167,14 @@ export class ClineProvider await this.persistStickyProviderProfileToCurrentTask(name, { skipCurrentTaskRebuild }) } + if (signal?.aborted) return + if (!skipCurrentTaskRebuild) { await this.postStateToWebview() } + if (signal?.aborted) return + if (providerSettings.apiProvider && !skipCurrentTaskRebuild) { this.emit(RooCodeEventName.ProviderProfileChanged, { name, provider: providerSettings.apiProvider }) } @@ -2654,6 +2787,8 @@ export class ClineProvider reasoningBlockCollapsed, chatFontSize, enterBehavior, + chatInputEffect, + tableStriped, cloudUserInfo, cloudIsAuthenticated, sharingEnabled, @@ -2833,6 +2968,8 @@ export class ClineProvider reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, chatFontSize, enterBehavior: enterBehavior ?? "send", + chatInputEffect: chatInputEffect ?? "marquee", + tableStriped, cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, cloudAuthSkipModel: this.context.globalState.get("roo-auth-skip-model") ?? false, @@ -3064,6 +3201,8 @@ export class ClineProvider reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, chatFontSize: stateValues.chatFontSize, enterBehavior: stateValues.enterBehavior ?? "send", + chatInputEffect: stateValues.chatInputEffect ?? "marquee", + tableStriped: stateValues.tableStriped, cloudUserInfo, cloudIsAuthenticated, sharingEnabled, diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 4561d688fe..ab6a67e780 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode" import { makeCompositeDisposable } from "../../../test-utils/vscode" import { TelemetryService } from "@roo-code/telemetry" -import { getModelId, RooCodeEventName } from "@roo-code/types" +import { getModelId, RooCodeEventName, type HistoryItem } from "@roo-code/types" import { ContextProxy } from "../../config/ContextProxy" import type { Mode } from "../../../shared/modes" @@ -490,7 +490,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "second-profile") }) - test("timed-out mutations abort before writing state and advance the queue", async () => { + test("timed-out mutations retain the queue lock until their underlying work settles", async () => { vi.useFakeTimers() const logSpy = vi.spyOn(provider, "log") const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") @@ -522,9 +522,9 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) await firstResult - // Queue advanced immediately on timeout — second enqueues now. + // A later activation stays queued even after the first caller times out. const second = provider.activateProviderProfile({ name: "second-profile" }) - // activateProfile not yet called for second (it runs in the next microtask). + await vi.advanceTimersByTimeAsync(1) expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(1) // Resolve the first activation's inner promise so its in-flight mock can return. @@ -541,6 +541,136 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { } }) + test("stops activation after a metadata read finishes following timeout", async () => { + let release!: () => void + const blocked = new Promise((resolve) => { + release = resolve + }) + const list = vi.spyOn(provider.providerSettingsManager, "listConfig").mockImplementationOnce(async () => { + await blocked + return [] + }) + const writes = vi.spyOn(provider.contextProxy, "setValue") + const settings = vi.spyOn(provider.contextProxy, "setProviderSettings") + const post = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.useFakeTimers() + try { + const activation = provider.activateProviderProfile({ name: "test-config" }) + const rejected = expect(activation).rejects.toThrow("Provider profile mutation timed out") + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + await rejected + expect(list).toHaveBeenCalledOnce() + release() + await provider["providerProfileMutationQueue"] + expect(writes).not.toHaveBeenCalled() + expect(settings).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.setModeConfig).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + } finally { + release() + vi.useRealTimers() + } + }) + + test("finishes a committed mode update after timeout before releasing the queue", async () => { + const task = new Task(defaultTaskOptions) + task["_taskMode"] = "code" + let persisted: HistoryItem = { + id: task.taskId, + ts: 1, + task: "Synthetic task", + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + } + provider["getTaskHistoryItem"] = vi.fn(() => persisted) + let release!: () => void + const blocked = new Promise((resolve) => { + release = resolve + }) + const history = vi.spyOn(provider, "updateTaskHistory").mockImplementationOnce(async (item) => { + await blocked + persisted = item + return [item] + }) + const post = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.useFakeTimers() + try { + const switching = provider.handleModeSwitch("ask", task) + const rejected = expect(switching).rejects.toThrow("Provider profile mutation timed out") + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + await rejected + expect(history).toHaveBeenCalledOnce() + const next = vi.fn(async () => { + expect(persisted.mode).toBe("ask") + expect(task["_taskMode"]).toBe("ask") + expect(provider.contextProxy.getValue("mode")).toBe("ask") + }) + const queued = provider["enqueueProviderProfileMutation"](next) + await vi.advanceTimersByTimeAsync(1) + expect(next).not.toHaveBeenCalled() + release() + await queued + expect(next).toHaveBeenCalledOnce() + expect(provider.providerSettingsManager.activateProfile).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + } finally { + release() + vi.useRealTimers() + } + }) + + test("stops a mode switch before activation when its profile read outlives the timeout", async () => { + let release!: () => void + const blocked = new Promise((resolve) => { + release = resolve + }) + vi.mocked(provider.providerSettingsManager.getModeConfigId).mockResolvedValue("test-id") + const profile = { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.openrouter } + const read = vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementationOnce(async () => { + await blocked + return profile + }) + const post = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.useFakeTimers() + try { + const switching = provider.handleModeSwitch("ask") + const rejected = expect(switching).rejects.toThrow("Provider profile mutation timed out") + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + await rejected + expect(read).toHaveBeenCalledOnce() + release() + await provider["providerProfileMutationQueue"] + expect(provider.providerSettingsManager.activateProfile).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.setModeConfig).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + } finally { + release() + vi.useRealTimers() + } + }) + + test("drains outstanding activation writes before releasing the queue on failure", async () => { + let release!: () => void + const blocked = new Promise((resolve) => { + release = resolve + }) + const failure = new Error("synthetic write failure") + vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(failure) + const settings = vi.spyOn(provider.contextProxy, "setProviderSettings").mockReturnValueOnce(blocked) + const first = provider.activateProviderProfile({ name: "test-config" }) + const rejected = expect(first).rejects.toThrow(failure) + const second = provider.activateProviderProfile({ name: "second-profile" }) + await vi.waitFor(() => expect(settings).toHaveBeenCalledOnce()) + expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledTimes(1) + release() + await rejected + await second + expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledTimes(2) + }) + test("mode switch preserves its default task when queued behind a profile mutation", async () => { let releaseProfileActivation!: () => void const profileActivation = new Promise((resolve) => { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 7c85a6b372..2117e1fb0c 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -20,6 +20,7 @@ import { DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_WRITE_DELAY_MS, providerIdentifiers, + retiredProviderIdentifiers, openAiModelInfoSaneDefaults, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -34,6 +35,8 @@ import { safeWriteJson } from "../../../utils/safeWriteJson" import { ClineProvider } from "../ClineProvider" import { webviewMessageHandler } from "../webviewMessageHandler" +import * as webviewMessages from "../webviewMessageHandler" +import { enqueueSettingsSave } from "../settingsSaveQueue" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" @@ -536,10 +539,7 @@ describe("ClineProvider", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + onDidDispose: vi.fn().mockReturnValue({ dispose: vi.fn() }), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), @@ -607,6 +607,7 @@ describe("ClineProvider", () => { function createView() { const messages = makeEventEmitter() + const onDidReceiveMessage = vi.fn(messages.event) const visibility = makeEventEmitter() const disposed = makeEventEmitter() const postMessage = vi.fn<(message: ExtensionMessage) => Promise>().mockResolvedValue(true) @@ -619,13 +620,13 @@ describe("ClineProvider", () => { cspSource: "vscode-webview://test-csp-source", asWebviewUri: (uri) => uri, postMessage, - onDidReceiveMessage: messages.event, + onDidReceiveMessage, }, onDidChangeVisibility: visibility.event, onDidDispose: disposed.event, show: vi.fn(), } - return { view, messages, visibility, disposed, postMessage } + return { view, messages, visibility, disposed, postMessage, onDidReceiveMessage } } beforeEach(async () => { @@ -671,6 +672,247 @@ describe("ClineProvider", () => { await target?.handleCodeAction("addToContext", "ADD_TO_CONTEXT", { selectedText: "selected code" }) } + test.each(["provider", "sidebar"] as const)( + "cancels saves immediately on %s disposal and stops a blocked batch", + async (target) => { + let release!: () => void + const blocked = new Promise((resolve) => { + release = resolve + }) + const write = vi.spyOn(provider.contextProxy, "setValue").mockImplementationOnce(() => blocked) + const saving = webviewMessageHandler(provider, { + type: "updateSettings", + updatedSettings: { soundVolume: 0.2, tableStriped: true }, + }) + const queued = webviewMessageHandler(provider, { + type: "updateSettings", + updatedSettings: { soundVolume: 0.8 }, + }) + const cancelled = Promise.all([ + expect(saving).rejects.toMatchObject({ name: "AbortError" }), + expect(queued).rejects.toMatchObject({ name: "AbortError" }), + ]) + if (target === "provider") { + await provider.dispose() + } else { + sidebar.disposed.fire() + } + await cancelled + await expect( + webviewMessageHandler(provider, { + type: "updateSettings", + updatedSettings: { soundVolume: 0.9 }, + }), + ).rejects.toMatchObject({ name: "AbortError" }) + release() + // Another provider sharing the context is a barrier for the cancelled batch. + await enqueueSettingsSave(provider.contextProxy, new AbortController().signal, async () => {}) + expect(write).toHaveBeenCalledTimes(1) + expect(write).toHaveBeenCalledWith("soundVolume", 0.2) + write.mockRestore() + }, + ) + + test.each(["provider", "sidebar"] as const)( + "registered callback suppresses the exact save abort reason on %s disposal", + async (target) => { + let release!: () => void + const write = vi.spyOn(provider.contextProxy, "setValue").mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }), + ) + const callback = sidebar.onDidReceiveMessage.mock.calls[0][0] + const saving = callback({ type: "updateSettings", updatedSettings: { tableStriped: true } }) + await vi.waitFor(() => expect(write).toHaveBeenCalled()) + if (target === "provider") await provider.dispose() + else sidebar.disposed.fire() + await expect(saving).resolves.toBeUndefined() + release() + await enqueueSettingsSave(provider.contextProxy, new AbortController().signal, async () => {}) + write.mockRestore() + }, + ) + + test.each([false, true])("registered callback rethrows a save error (aborted: %s)", async (abort) => { + // Control the handler rejection to exercise the wrapper independently of the queue's abort race. + let reject!: (error: Error) => void + const handler = vi.spyOn(webviewMessages, "webviewMessageHandler").mockImplementationOnce( + () => + new Promise((_, rejectSave) => { + reject = rejectSave + }), + ) + const callback = sidebar.onDidReceiveMessage.mock.calls[0][0] + const error = new Error("save failed") + const saving = callback({ type: "updateSettings", updatedSettings: { tableStriped: true } }) + const rejected = expect(saving).rejects.toBe(error) + if (abort) sidebar.disposed.fire() + expect(error).not.toBe(provider.settingsSaveSignal.reason) + reject(error) + await rejected + handler.mockRestore() + }) + + test.each(["save", "metadata", "settings"] as const)( + "finishes profile persistence after disposal during %s and delays the next webview state", + async (stage) => { + let release!: () => void + const blocked = new Promise((resolve) => { + release = resolve + }) + const configuration: ProviderSettings = { apiProvider: providerIdentifiers.openrouter } + const metadata = [{ id: "saved-id", name: "saved-profile", ...configuration }] + const save = vi.spyOn(provider.providerSettingsManager, "saveConfig").mockImplementation(async () => { + if (stage === "save") await blocked + return "saved-id" + }) + const list = vi.spyOn(provider.providerSettingsManager, "listConfig").mockImplementation(async () => { + if (stage === "metadata") await blocked + return metadata + }) + const setMode = vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + const setProviderSettings = provider.contextProxy.setProviderSettings.bind(provider.contextProxy) + const settings = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockImplementation(async (values) => { + if (stage === "settings") await blocked + await setProviderSettings(values) + }) + const task = new Task(defaultTaskOptions) + task.updateApiConfiguration = vi.fn() + task.setTaskApiConfigName = vi.fn() + const currentTask = vi.spyOn(provider, "getCurrentTask").mockReturnValue(task) + const upsert = vi.spyOn(provider, "upsertProviderProfile") + const saving = webviewMessageHandler(provider, { + type: "upsertApiConfiguration", + text: "saved-profile", + apiConfiguration: configuration, + }) + await vi.waitFor(() => + expect(stage === "save" ? save : stage === "metadata" ? list : settings).toHaveBeenCalled(), + ) + const cancelled = expect(saving).rejects.toMatchObject({ name: "AbortError" }) + sidebar.disposed.fire() + await cancelled + await provider.resolveWebviewView(createView().view) + const post = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const previousReads = list.mock.calls.length + const launching = webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + await new Promise((resolve) => setImmediate(resolve)) + expect(list).toHaveBeenCalledTimes(previousReads) + expect(post).not.toHaveBeenCalled() + release() + await launching + await expect(upsert.mock.results[0].value).rejects.toMatchObject({ name: "AbortError" }) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual(metadata) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("saved-profile") + expect(provider.contextProxy.getValue("apiProvider")).toBe(providerIdentifiers.openrouter) + expect(setMode).toHaveBeenCalledWith(defaultModeSlug, "saved-id") + expect(task.updateApiConfiguration).toHaveBeenCalledWith(configuration) + expect(task.setTaskApiConfigName).toHaveBeenCalledWith("saved-profile") + for (const spy of [save, list, setMode, settings, upsert, post, currentTask]) spy.mockRestore() + }, + ) + + test("logs slow profile writes and keeps their lock when a queued mutation times out", async () => { + let release!: () => void + const blocked = new Promise((resolve) => { + release = resolve + }) + const log = vi.spyOn(provider, "log") + vi.useFakeTimers() + try { + const saving = provider["enqueueProviderProfileMutation"](() => blocked, { allowTimeout: false }) + const timedOutWrite = vi.fn().mockResolvedValue(undefined) + const timingOut = provider["enqueueProviderProfileMutation"](timedOutWrite) + const rejected = expect(timingOut).rejects.toThrow("Provider profile mutation timed out") + const nextWrite = vi.fn().mockResolvedValue(undefined) + const queued = provider["enqueueProviderProfileMutation"](nextWrite, { allowTimeout: false }) + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + await rejected + expect(log).toHaveBeenCalledWith( + "Provider profile save is still pending; waiting for persistence to finish", + ) + expect(nextWrite).not.toHaveBeenCalled() + release() + await saving + await queued + expect(nextWrite).toHaveBeenCalledOnce() + expect(timedOutWrite).not.toHaveBeenCalled() + } finally { + release() + vi.useRealTimers() + log.mockRestore() + } + }) + + test("an inactive profile save finishes metadata persistence after cancellation", async () => { + let release!: () => void + const metadata = [{ id: "saved-id", name: "saved-profile" }] + const save = vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("saved-id") + const list = vi.spyOn(provider.providerSettingsManager, "listConfig").mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(metadata) + }), + ) + const saving = provider.upsertProviderProfile("saved-profile", {}, false, provider.settingsSaveSignal) + const cancelled = expect(saving).rejects.toMatchObject({ name: "AbortError" }) + await vi.waitFor(() => expect(list).toHaveBeenCalled()) + sidebar.disposed.fire() + release() + await cancelled + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual(metadata) + expect(provider.contextProxy.getValue("currentApiConfigName")).not.toBe("saved-profile") + save.mockRestore() + list.mockRestore() + }) + + test("a partial profile failure waits for outstanding writes and remains retryable", async () => { + let release!: () => void + const metadata = [{ id: "saved-id", name: "saved-profile" }] + const save = vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("saved-id") + const list = vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue(metadata) + const setMode = vi + .spyOn(provider.providerSettingsManager, "setModeConfig") + .mockRejectedValueOnce(new Error("write failed")) + const settings = vi.spyOn(provider.contextProxy, "setProviderSettings").mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }), + ) + const saving = provider.upsertProviderProfile("saved-profile", {}) + const finished = vi.fn() + void saving.then(finished) + await vi.waitFor(() => expect(settings).toHaveBeenCalled()) + expect(finished).not.toHaveBeenCalled() + release() + await expect(saving).resolves.toBeUndefined() + setMode.mockResolvedValue(undefined) + await expect(provider.upsertProviderProfile("saved-profile", {})).resolves.toBe("saved-id") + expect(list).toHaveBeenCalledTimes(2) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual(metadata) + for (const spy of [save, list, setMode, settings]) spy.mockRestore() + }) + + test("allows settings saves when a disposed sidebar is resolved again", async () => { + const oldSignal = provider.settingsSaveSignal + sidebar.disposed.fire() + expect(oldSignal.aborted).toBe(true) + await provider.resolveWebviewView(createView().view) + expect(provider.settingsSaveSignal.aborted).toBe(false) + const write = vi.spyOn(provider.contextProxy, "setValue").mockResolvedValue(undefined) + await webviewMessageHandler(provider, { + type: "updateSettings", + updatedSettings: { tableStriped: true }, + }) + expect(write).toHaveBeenCalledWith("tableStriped", true) + write.mockRestore() + }) + test("reports current visibility independently of panel activation", () => { expect(provider.isViewVisible).toBe(true) expect(tabProvider.isViewVisible).toBe(true) @@ -1799,6 +2041,410 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalled() }) + test("launches with cached state when refreshing profile metadata fails", async () => { + await provider.resolveWebviewView(mockWebviewView) + const list = vi + .spyOn(provider.providerSettingsManager, "listConfig") + .mockRejectedValue(new Error("read failed")) + const post = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const log = vi.spyOn(provider, "log") + await expect(webviewMessageHandler(provider, { type: "webviewDidLaunch" })).resolves.toBeUndefined() + expect(post).toHaveBeenCalled() + expect(provider.isViewLaunched).toBe(true) + expect(log).toHaveBeenCalledWith("Failed to refresh provider profile metadata on launch") + for (const spy of [list, post, log]) spy.mockRestore() + }) + + describe("launch profile repair", () => { + const replacement: ProviderSettings = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "replacement-model", + apiKey: "synthetic-replacement-key", + } + const stale: ProviderSettings = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "deleted-model", + apiKey: "synthetic-deleted-key", + openAiBaseUrl: "https://stale.invalid", + } + const profilesKey = "roo_cline_config_api_config" + + beforeEach(async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.providerSettingsManager.saveConfig("default", replacement) + await provider.providerSettingsManager.saveConfig("second", { apiProvider: providerIdentifiers.fakeAi }) + await provider.providerSettingsManager.saveConfig("deleted", stale) + await provider.providerSettingsManager.activateProfile({ name: "deleted" }, defaultModeSlug) + await provider.providerSettingsManager.deleteConfig("deleted") + await provider.contextProxy.setProviderSettings(stale) + await provider.contextProxy.setValue("currentApiConfigName", "deleted") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + }) + + afterEach(() => vi.mocked(provider.postStateToWebview).mockRestore()) + + test("does not copy approval settings or unknown fields from a persisted retired profile", async () => { + const persisted = JSON.parse((await mockContext.secrets.get(profilesKey))!) + persisted.apiConfigs.default = { + ...persisted.apiConfigs.default, + apiProvider: retiredProviderIdentifiers.groq, + groqApiKey: "synthetic-legacy-key", + alwaysAllowExecute: true, + } + await mockContext.secrets.store(profilesKey, JSON.stringify(persisted)) + await provider.contextProxy.setValue("alwaysAllowExecute", false) + const writes = vi.spyOn(provider.contextProxy, "setValue").mockClear() + + await webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("default") + expect(provider.contextProxy.getValue("apiProvider")).toBe(retiredProviderIdentifiers.groq) + expect(provider.contextProxy.getValue("alwaysAllowExecute")).toBe(false) + expect(mockContext.globalState.get("alwaysAllowExecute")).toBe(false) + expect(writes.mock.calls.map(([key]) => key)).not.toContain("alwaysAllowExecute") + expect(writes.mock.calls.map(([key]) => key)).not.toContain("groqApiKey") + writes.mockRestore() + }) + + test("loads the replacement before the next settings save and persists both active names and the mode", async () => { + const launching = webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + const nextSave = enqueueSettingsSave(provider.contextProxy, provider.settingsSaveSignal, async () => { + const { currentApiConfigName, apiConfiguration } = await provider.getState() + expect(currentApiConfigName).toBe("default") + expect(apiConfiguration).toMatchObject(replacement) + expect(apiConfiguration.openAiBaseUrl).toBeUndefined() + await provider.upsertProviderProfile(currentApiConfigName!, apiConfiguration) + }) + await Promise.all([launching, nextSave]) + expect(await provider.providerSettingsManager.getProfile({ name: "default" })).toMatchObject(replacement) + const persisted = JSON.parse((await mockContext.secrets.get(profilesKey))!) + expect(persisted.currentApiConfigName).toBe("default") + expect(persisted.modeApiConfigs[defaultModeSlug]).toBe(persisted.apiConfigs.default.id) + expect(mockContext.globalState.get("currentApiConfigName")).toBe("default") + expect(mockContext.globalState.get("apiModelId")).toBe(replacement.apiModelId) + expect(await mockContext.secrets.get("apiKey")).toBe(replacement.apiKey) + }) + + test.each(["apiModelId", "apiKey", "listApiConfigMeta", "currentApiConfigName", profilesKey])( + "rolls back a failed %s write and repairs successfully on retry", + async (failedKey) => { + const previous = provider.contextProxy.getValues() + const profilesBefore = await mockContext.secrets.get(profilesKey) + const update = vi.mocked(mockContext.globalState.update).getMockImplementation()! + const store = vi.mocked(mockContext.secrets.store).getMockImplementation()! + let failed = false + const failOnce = (key: string) => { + if (key === failedKey && !failed) { + failed = true + throw new Error("synthetic persistence failure") + } + } + vi.mocked(mockContext.globalState.update).mockImplementation(async (key, value) => { + failOnce(key) + await update(key, value) + }) + vi.mocked(mockContext.secrets.store).mockImplementation(async (key, value) => { + failOnce(key) + await store(key, value) + }) + await webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + expect(failed).toBe(true) + for (const key of [ + "apiModelId", + "apiKey", + "openAiBaseUrl", + "listApiConfigMeta", + "currentApiConfigName", + ] as const) { + expect(provider.contextProxy.getValue(key)).toEqual(previous[key]) + } + expect(mockContext.globalState.get("apiModelId")).toBe(stale.apiModelId) + expect(mockContext.globalState.get("currentApiConfigName")).toBe("deleted") + expect(await mockContext.secrets.get("apiKey")).toBe(stale.apiKey) + expect(await mockContext.secrets.get(profilesKey)).toBe(profilesBefore) + await webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + expect((await provider.getState()).apiConfiguration).toMatchObject(replacement) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("default") + }, + ) + + test("continues after a rollback write fails, logs the unsaved field, and lets a queued repair retry", async () => { + const previous = provider.contextProxy.getValues() + const profilesBefore = await mockContext.secrets.get(profilesKey) + const metadata = await provider.providerSettingsManager.listConfig() + const signal = provider.settingsSaveSignal + const forwardError = new Error("synthetic forward persistence failure") + const update = vi.mocked(mockContext.globalState.update).getMockImplementation()! + const store = vi.mocked(mockContext.secrets.store).getMockImplementation()! + const writes = vi.spyOn(provider.contextProxy, "setValue").mockClear() + const log = vi.spyOn(provider, "log") + let forwardFailed = false + let rollbackStarted = false + let releaseRollback!: () => void + const blockedRollback = new Promise((resolve) => { + releaseRollback = resolve + }) + vi.mocked(mockContext.globalState.update).mockImplementation(async (key, value) => { + if (key === "currentApiConfigName" && !forwardFailed) { + forwardFailed = true + throw forwardError + } + await update(key, value) + }) + vi.mocked(mockContext.secrets.store).mockImplementation(async (key, value) => { + if (key === "apiKey" && value === stale.apiKey) { + rollbackStarted = true + await blockedRollback + throw new Error("synthetic rollback persistence failure") + } + await store(key, value) + }) + const repairing = enqueueSettingsSave(provider.contextProxy, signal, () => + provider.repairProviderProfile("default", metadata, signal), + ) + const rejected = expect(repairing).rejects.toBe(forwardError) + await vi.waitFor(() => expect(rollbackStarted).toBe(true)) + const retry = vi.fn(async () => { + // Every attempted write is rolled back in reverse order, even after apiKey fails. + const forwardEnd = writes.mock.calls.findIndex(([key]) => key === "currentApiConfigName") + 1 + const forwardKeys = writes.mock.calls.slice(0, forwardEnd).map(([key]) => key) + expect(writes.mock.calls.slice(forwardEnd)).toEqual( + forwardKeys.reverse().map((key) => [key, previous[key]]), + ) + expect(log).toHaveBeenCalledWith( + "Provider profile repair rollback failed; unsaved fields (retry required): apiKey", + ) + expect(mockContext.globalState.get("apiModelId")).toBe(stale.apiModelId) + expect(mockContext.globalState.get("openAiBaseUrl")).toBe(stale.openAiBaseUrl) + expect(mockContext.globalState.get("currentApiConfigName")).toBe("deleted") + expect(await mockContext.secrets.get("apiKey")).toBe(replacement.apiKey) + expect(await mockContext.secrets.get(profilesKey)).toBe(profilesBefore) + vi.mocked(mockContext.globalState.update).mockImplementation(update) + vi.mocked(mockContext.secrets.store).mockImplementation(store) + await provider.repairProviderProfile("default", metadata, signal) + }) + const queued = enqueueSettingsSave(provider.contextProxy, signal, retry) + try { + expect(retry).not.toHaveBeenCalled() + } finally { + releaseRollback() + } + await Promise.all([rejected, queued]) + expect(retry).toHaveBeenCalledOnce() + expect((await provider.getState()).apiConfiguration).toMatchObject(replacement) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("default") + expect(mockContext.globalState.get("currentApiConfigName")).toBe("default") + expect(mockContext.globalState.get("apiModelId")).toBe(replacement.apiModelId) + expect(mockContext.globalState.get("openAiBaseUrl")).toBeUndefined() + expect(await mockContext.secrets.get("apiKey")).toBe(replacement.apiKey) + const persisted = JSON.parse((await mockContext.secrets.get(profilesKey))!) + expect(persisted.currentApiConfigName).toBe("default") + expect(persisted.modeApiConfigs[defaultModeSlug]).toBe(persisted.apiConfigs.default.id) + writes.mockRestore() + log.mockRestore() + }) + + test("finishes an in-flight repair after cancellation before releasing the settings queue", async () => { + const controller = new AbortController() + let release!: () => void + const blocked = new Promise((resolve) => { + release = resolve + }) + const update = vi.mocked(mockContext.globalState.update).getMockImplementation()! + const writing = vi.fn() + vi.mocked(mockContext.globalState.update).mockImplementation(async (key, value) => { + if (key === "apiModelId") { + writing() + await blocked + } + await update(key, value) + }) + const metadata = await provider.providerSettingsManager.listConfig() + const repairing = enqueueSettingsSave(provider.contextProxy, controller.signal, () => + provider.repairProviderProfile("default", metadata, controller.signal), + ) + await vi.waitFor(() => expect(writing).toHaveBeenCalled()) + const cancelled = expect(repairing).rejects.toMatchObject({ name: "AbortError" }) + controller.abort() + await cancelled + const next = vi.fn(async () => { + expect((await provider.getState()).apiConfiguration).toMatchObject(replacement) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("default") + }) + const queued = enqueueSettingsSave(provider.contextProxy, provider.settingsSaveSignal, next) + expect(next).not.toHaveBeenCalled() + release() + await queued + expect(next).toHaveBeenCalledOnce() + }) + }) + + test("serializes first-run profile sync, name repair, and list posting with settings saves", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("currentApiConfigName", "missing-profile") + const configuration: ProviderSettings = { apiProvider: providerIdentifiers.fakeAi } + const metadata: ProviderSettingsEntry[] = [{ id: "default-id", name: "default" }] + const refreshedMetadata: ProviderSettingsEntry[] = [ + { ...metadata[0], apiProvider: providerIdentifiers.fakeAi, modelId: "saved-model" }, + ] + const list = vi + .spyOn(provider.providerSettingsManager, "listConfig") + .mockResolvedValueOnce(metadata) + .mockResolvedValue(refreshedMetadata) + const hasConfig = vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(false) + const state = vi.spyOn(provider, "getState").mockResolvedValue({ + ...(await provider.getState()), + apiConfiguration: configuration, + }) + let release!: () => void + const save = vi.spyOn(provider.providerSettingsManager, "saveConfig").mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve("default-id") + }), + ) + const post = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const message = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const activate = vi.spyOn(provider, "activateProviderProfile") + try { + const launching = webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + await vi.waitFor(() => expect(save).toHaveBeenCalledWith("default", configuration)) + const nextWrite = vi.fn(async () => { + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("default") + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual(refreshedMetadata) + expect(message).toHaveBeenCalledWith({ type: "listApiConfig", listApiConfig: refreshedMetadata }) + }) + const queued = enqueueSettingsSave(provider.contextProxy, provider.settingsSaveSignal, nextWrite) + expect(nextWrite).not.toHaveBeenCalled() + expect(hasConfig).not.toHaveBeenCalled() + expect(message).not.toHaveBeenCalled() + release() + await Promise.all([launching, queued]) + expect(list).toHaveBeenCalledTimes(2) + expect(hasConfig).toHaveBeenCalledWith("missing-profile") + expect(metadata[0].apiProvider).toBeUndefined() + expect(nextWrite).toHaveBeenCalledOnce() + expect(activate).not.toHaveBeenCalled() + } finally { + release?.() + for (const spy of [list, hasConfig, state, save, post, message, activate]) spy.mockRestore() + } + }) + + test.each(["missing-profile", "default"])( + "refreshes saved first-run model metadata with current profile %s and recovers from a failed refresh", + async (currentName) => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("currentApiConfigName", currentName) + const configuration: ProviderSettings = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "synthetic-key", + apiModelId: "vendor/saved-model", + } + await provider.contextProxy.setProviderSettings(configuration) + const metadata = await provider.providerSettingsManager.listConfig() + await provider.contextProxy.setValue("listApiConfigMeta", metadata) + const list = vi + .spyOn(provider.providerSettingsManager, "listConfig") + .mockResolvedValueOnce(metadata) + .mockRejectedValueOnce(new Error("synthetic metadata refresh failure")) + const save = vi.spyOn(provider.providerSettingsManager, "saveConfig") + const repair = vi.spyOn(provider, "repairProviderProfile") + const post = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const message = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const log = vi.spyOn(provider, "log") + try { + await webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + expect(save).toHaveBeenCalledWith("default", expect.objectContaining(configuration)) + expect(list).toHaveBeenCalledTimes(2) + expect(repair).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe(currentName) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual(metadata) + expect(mockContext.globalState.get("listApiConfigMeta")).toEqual(metadata) + expect(message).not.toHaveBeenCalledWith(expect.objectContaining({ type: "listApiConfig" })) + expect(log).toHaveBeenCalledWith("Failed to refresh provider profile metadata on launch") + expect(post).toHaveBeenCalled() + + // Retry against the saved profile using the real metadata reader. + await webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + const refreshedMetadata = [ + { ...metadata[0], apiProvider: configuration.apiProvider, modelId: "saved-model" }, + ] + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual(refreshedMetadata) + expect(mockContext.globalState.get("listApiConfigMeta")).toEqual(refreshedMetadata) + expect(message).toHaveBeenCalledWith({ type: "listApiConfig", listApiConfig: refreshedMetadata }) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("default") + if (currentName === "missing-profile") { + expect(repair).toHaveBeenCalledWith("default", refreshedMetadata, provider.settingsSaveSignal) + } else { + expect(repair).not.toHaveBeenCalled() + } + } finally { + for (const spy of [list, save, repair, post, message, log]) spy.mockRestore() + } + }, + ) + + test("does not sync an unconfigured fallback into the first-run profile", async () => { + await provider.resolveWebviewView(mockWebviewView) + const metadata: ProviderSettingsEntry[] = [{ id: "default-id", name: "default" }] + const list = vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue(metadata) + const state = vi.spyOn(provider, "getState").mockResolvedValue({ + ...(await provider.getState()), + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + const save = vi.spyOn(provider.providerSettingsManager, "saveConfig") + const post = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const message = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + try { + await webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + expect(save).not.toHaveBeenCalled() + expect(list).toHaveBeenCalledOnce() + expect(message).toHaveBeenCalledWith({ type: "listApiConfig", listApiConfig: metadata }) + } finally { + for (const spy of [list, state, save, post, message]) spy.mockRestore() + } + }) + + test("bounds the launch wait without releasing the save queue, then posts refreshed state", async () => { + await provider.resolveWebviewView(mockWebviewView) + const list = vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + const post = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + let release!: () => void + const saving = enqueueSettingsSave( + provider.contextProxy, + provider.settingsSaveSignal, + () => + new Promise((resolve) => { + release = resolve + }), + ) + vi.useFakeTimers() + try { + const launching = webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + const nextWrite = vi.fn().mockResolvedValue(undefined) + const queued = enqueueSettingsSave(provider.contextProxy, provider.settingsSaveSignal, nextWrite) + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + await launching + expect(provider.isViewLaunched).toBe(true) + expect(post).toHaveBeenCalled() + expect(list).not.toHaveBeenCalled() + expect(nextWrite).not.toHaveBeenCalled() + post.mockClear() + release() + await saving + await queued + expect(list).toHaveBeenCalledOnce() + expect(nextWrite).toHaveBeenCalledOnce() + expect(post).toHaveBeenCalledOnce() + } finally { + release() + vi.useRealTimers() + list.mockRestore() + post.mockRestore() + } + }) + test("logs detached workspace initialization failures", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -1977,6 +2623,31 @@ describe("ClineProvider", () => { expect(state).toHaveProperty("writeDelayMs") }) + describe.each(["getState", "getStateToPostToWebview"] as const)("%s chat appearance settings", (method) => { + test.each([ + { chatInputEffect: "breathing", tableStriped: true }, + { chatInputEffect: "marquee", tableStriped: false }, + ] as const)( + "returns saved chatInputEffect=$chatInputEffect and tableStriped=$tableStriped", + async (settings) => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("chatInputEffect", settings.chatInputEffect) + await provider.contextProxy.setValue("tableStriped", settings.tableStriped) + + expect(await provider[method]()).toMatchObject(settings) + }, + ) + + test("defaults unset chatInputEffect to marquee and leaves tableStriped unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const state = await provider[method]() + expect(state).toMatchObject({ chatInputEffect: "marquee" }) + // Unset tableStriped stays undefined so the legacy striped default flows to the webview. + expect(state.tableStriped).toBeUndefined() + }) + }) + test("getState and getStateToPostToWebview return the complete NanoGPT configuration", async () => { await provider.resolveWebviewView(mockWebviewView) await provider.contextProxy.setProviderSettings({ diff --git a/src/core/webview/__tests__/settingsSaveQueue.spec.ts b/src/core/webview/__tests__/settingsSaveQueue.spec.ts new file mode 100644 index 0000000000..923ab8090c --- /dev/null +++ b/src/core/webview/__tests__/settingsSaveQueue.spec.ts @@ -0,0 +1,47 @@ +import { enqueueSettingsSave } from "../settingsSaveQueue" + +describe("settings save queue", () => { + it("removes cancelled callers without releasing an active write's storage lock", async () => { + const context = {} + const closedView = new AbortController() + const liveView = new AbortController() + let release!: () => void + const active = enqueueSettingsSave( + context, + closedView.signal, + () => + new Promise((resolve) => { + release = resolve + }), + ) + const cancelledWrite = vi.fn() + const pending = enqueueSettingsSave(context, closedView.signal, cancelledWrite) + const liveWrite = vi.fn().mockResolvedValue(undefined) + const next = enqueueSettingsSave(context, liveView.signal, liveWrite) + const cancellations = Promise.all([ + expect(active).rejects.toMatchObject({ name: "AbortError" }), + expect(pending).rejects.toMatchObject({ name: "AbortError" }), + ]) + closedView.abort() + await cancellations + expect(cancelledWrite).not.toHaveBeenCalled() + expect(liveWrite).not.toHaveBeenCalled() + release() + await next + expect(cancelledWrite).not.toHaveBeenCalled() + expect(liveWrite).toHaveBeenCalledOnce() + }) + + it("cleans up listeners after failure and accepts saves after the queue drains", async () => { + const context = {} + const controller = new AbortController() + const remove = vi.spyOn(controller.signal, "removeEventListener") + await expect( + enqueueSettingsSave(context, controller.signal, async () => { + throw new Error("write failed") + }), + ).rejects.toThrow("write failed") + await enqueueSettingsSave(context, controller.signal, async () => {}) + expect(remove).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index dcd70f92f1..1c3fb3063e 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -69,9 +69,13 @@ vi.mock("@roo-code/telemetry", () => ({ }, })) +import { ContextProxy } from "../../config/ContextProxy" +import { makeExtensionContext } from "../../../test-utils/vscode" + import type { ModelRecord } from "@roo-code/types" import { webviewMessageHandler } from "../webviewMessageHandler" +import { enqueueSettingsSave } from "../settingsSaveQueue" import type { ClineProvider } from "../ClineProvider" import { flushModels, getModels } from "../../../api/providers/fetchers/modelCache" import { getLMStudioModels } from "../../../api/providers/fetchers/lmstudio" @@ -96,7 +100,11 @@ const mockGetAccountId = vi.mocked(openAiCodexOAuthManager.getAccountId) const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInfo) // Mock ClineProvider +const mockSettingsSaveController = new AbortController() const mockClineProvider = { + get settingsSaveSignal() { + return mockSettingsSaveController.signal + }, getState: vi.fn(), postMessageToWebview: vi.fn(), customModesManager: { @@ -116,6 +124,7 @@ const mockClineProvider = { getValue: vi.fn(), }, log: vi.fn(), + upsertProviderProfile: vi.fn(), postStateToWebview: vi.fn(), resolveWebviewThemeFixtureProbe: vi.fn(), getCurrentTask: vi.fn(), @@ -169,7 +178,7 @@ describe("webviewMessageHandler - theme fixture probes", () => { }) }) -import { t } from "../../../i18n" +import { changeLanguage, t } from "../../../i18n" vi.mock("vscode", () => { const showInformationMessage = vi.fn() @@ -178,6 +187,7 @@ vi.mock("vscode", () => { const showTextDocument = vi.fn().mockResolvedValue(undefined) return { + ConfigurationTarget: { Global: 1 }, window: { showInformationMessage, showErrorMessage, @@ -213,6 +223,7 @@ vi.mock("../../../i18n", () => ({ } return key }), + changeLanguage: vi.fn(), })) vi.mock("fs/promises", () => { @@ -2307,3 +2318,707 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) }) + +describe("bulk settings save results", () => { + beforeEach(() => vi.clearAllMocks()) + + it("escapes untrusted setting names and error names in persistence failure logs", async () => { + const key = "unknown\r\nForged entry\t\u001b[31m" + // Runtime webview messages can contain keys absent from the TypeScript type. + const updatedSettings = { chatInputEffect: "breathing" as const, [key]: true } + const error = new Error("secret value") + error.name = "Error\nForged error" + vi.mocked(mockClineProvider.contextProxy.setValue).mockRejectedValueOnce(error) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "untrusted-settings", + updatedSettings, + }) + + expect(mockClineProvider.log).toHaveBeenCalledExactlyOnceWith( + `Failed to save settings: ${JSON.stringify(["chatInputEffect", key])}; error: ${JSON.stringify(error.name)}`, + ) + expect(vi.mocked(mockClineProvider.log).mock.calls[0][0]).not.toContain("\n") + expect(vi.mocked(mockClineProvider.log).mock.calls[0][0]).not.toContain("secret value") + }) + + it("persists a changed retry after a save stalled past the webview timeout", async () => { + vi.useFakeTimers() + const context = makeExtensionContext() + const persisted = new Map() + let releaseWrite!: () => void + let writeStarted!: () => void + const delayedWrite = new Promise((resolve) => { + releaseWrite = resolve + }) + const started = new Promise((resolve) => { + writeStarted = resolve + }) + const update = vi.mocked(context.globalState.update).mockImplementation(async (key, value) => { + if (key === "chatInputEffect" && value === "breathing") { + writeStarted() + await delayedWrite + } + persisted.set(key, value) + }) + const contextProxy = new ContextProxy(context) + const provider = { ...mockClineProvider, contextProxy } as ClineProvider + const first = webviewMessageHandler(provider, { + type: "updateSettings", + requestId: "timed-out-save", + updatedSettings: { chatInputEffect: "breathing", tableStriped: true }, + }) + let retry: Promise | undefined + try { + await started + // SettingsView releases its UI lock after 30 seconds without cancelling the host write. + await vi.advanceTimersByTimeAsync(30_001) + retry = webviewMessageHandler(provider, { + type: "updateSettings", + requestId: "retry", + updatedSettings: { chatInputEffect: "marquee", tableStriped: false }, + }) + await vi.advanceTimersByTimeAsync(0) + expect(update).toHaveBeenCalledExactlyOnceWith("chatInputEffect", "breathing") + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + } finally { + releaseWrite() + await Promise.all([first, retry]) + vi.useRealTimers() + } + expect(contextProxy.getValue("chatInputEffect")).toBe("marquee") + expect(contextProxy.getValue("tableStriped")).toBe(false) + expect(persisted.get("chatInputEffect")).toBe("marquee") + expect(persisted.get("tableStriped")).toBe(false) + expect(provider.postMessageToWebview).toHaveBeenLastCalledWith({ + type: "settingsSaveResult", + requestId: "retry", + success: true, + unsavedSettings: [], + }) + }) + + it("acknowledges unset experiments and customSupportPrompts without persisting them", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "skipped-settings", + updatedSettings: { experiments: undefined, customSupportPrompts: undefined }, + }) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "settingsSaveResult", + requestId: "skipped-settings", + success: true, + unsavedSettings: [], + }) + }) + + it("excludes skipped experiments and customSupportPrompts when a later write fails", async () => { + const setValue = vi.mocked(mockClineProvider.contextProxy.setValue) + setValue.mockRejectedValueOnce(new Error("storage unavailable")) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "skipped-settings-with-failure", + updatedSettings: { experiments: undefined, customSupportPrompts: undefined, tableStriped: true }, + }) + + expect(setValue).toHaveBeenCalledExactlyOnceWith("tableStriped", true) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "settingsSaveResult", + requestId: "skipped-settings-with-failure", + success: false, + unsavedSettings: ["tableStriped"], + }) + }) + + it("shows the unsaved keys when a write fails without a request ID", async () => { + const setValue = vi.mocked(mockClineProvider.contextProxy.setValue) + setValue.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error("storage unavailable")) + const errorMessage = "Could not save settings: chatInputEffect, tableStriped" + vi.mocked(t).mockReturnValueOnce(errorMessage) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { soundEnabled: true, chatInputEffect: "breathing", tableStriped: true }, + }) + + expect(setValue).toHaveBeenCalledTimes(2) + expect(mockClineProvider.log).toHaveBeenCalledExactlyOnceWith( + 'Failed to save settings: ["chatInputEffect","tableStriped"]; error: "Error"', + ) + expect(t).toHaveBeenCalledWith("common:errors.settingsSaveFailed", { + keys: "chatInputEffect, tableStriped", + }) + expect(vscode.window.showErrorMessage).toHaveBeenCalledExactlyOnceWith(errorMessage) + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "settingsSaveResult" }), + ) + }) + + it("reports the failed and unattempted keys, then permits a successful retry", async () => { + const setValue = vi.mocked(mockClineProvider.contextProxy.setValue) + setValue.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error("storage unavailable")) + const updatedSettings = { soundEnabled: true, chatInputEffect: "breathing" as const, tableStriped: true } + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "failed-save", + updatedSettings, + }) + expect(setValue).toHaveBeenCalledTimes(2) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "failed-save", + success: false, + unsavedSettings: ["chatInputEffect", "tableStriped"], + }) + setValue.mockResolvedValue(undefined) + await webviewMessageHandler(mockClineProvider, { type: "updateSettings", requestId: "retry", updatedSettings }) + expect(setValue).toHaveBeenLastCalledWith("tableStriped", true) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "retry", + success: true, + unsavedSettings: [], + }) + }) + + it("reports all keys when the first write fails", async () => { + vi.mocked(mockClineProvider.contextProxy.setValue).mockRejectedValueOnce(new Error("disk full")) + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "first-failure", + updatedSettings: { chatInputEffect: "breathing", tableStriped: true }, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "first-failure", + success: false, + unsavedSettings: ["chatInputEffect", "tableStriped"], + }) + }) +}) + +describe("individual settings save acknowledgments", () => { + beforeEach(() => vi.clearAllMocks()) + + it.each(["upsertApiConfiguration", "telemetrySetting", "debugSetting"] as const)( + "acknowledges %s only after persistence completes, and reports failures", + async (type) => { + const write = vi.fn<() => Promise>() + if (type === "upsertApiConfiguration") { + vi.mocked(mockClineProvider.upsertProviderProfile).mockImplementation(write) + } else if (type === "telemetrySetting") { + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async () => { + await write() + }) + } else { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ update: write } as never) + } + let resolveWrite!: (value: string) => void + write.mockReturnValueOnce( + new Promise((resolve) => { + resolveWrite = resolve + }), + ) + const message = { type, requestId: "save", text: "enabled", bool: true, apiConfiguration: {} } + const saving = webviewMessageHandler(mockClineProvider, message) + await vi.waitFor(() => expect(write).toHaveBeenCalledOnce()) + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + resolveWrite("profile-id") + await saving + expect(mockClineProvider.postMessageToWebview).toHaveBeenLastCalledWith({ + type: "settingsSaveResult", + requestId: "save", + success: true, + unsavedSettings: [], + }) + write.mockRejectedValueOnce(new TypeError("secret error text")) + await webviewMessageHandler(mockClineProvider, message) + const key = + type === "upsertApiConfiguration" ? "apiConfiguration" : type === "debugSetting" ? "debug" : type + expect(mockClineProvider.postMessageToWebview).toHaveBeenLastCalledWith({ + type: "settingsSaveResult", + requestId: "save", + success: false, + unsavedSettings: [key], + }) + expect(mockClineProvider.log).toHaveBeenLastCalledWith( + `Failed to save settings: ${JSON.stringify([key])}; error: "TypeError"`, + ) + write.mockResolvedValue("profile-id") + await webviewMessageHandler(mockClineProvider, message) + expect(mockClineProvider.postMessageToWebview).toHaveBeenLastCalledWith({ + type: "settingsSaveResult", + requestId: "save", + success: true, + unsavedSettings: [], + }) + }, + ) + + it("reports profile persistence failures returned as undefined", async () => { + vi.mocked(mockClineProvider.upsertProviderProfile).mockResolvedValueOnce(undefined) + await webviewMessageHandler(mockClineProvider, { + type: "upsertApiConfiguration", + requestId: "save", + text: "default", + apiConfiguration: {}, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenLastCalledWith({ + type: "settingsSaveResult", + requestId: "save", + success: false, + unsavedSettings: ["apiConfiguration"], + }) + }) + + it("rejects an upsertApiConfiguration save that arrives without a profile payload", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "upsertApiConfiguration", + requestId: "save", + }) + // The handler must fail fast instead of silently succeeding when the webview forgot the payload. + expect(mockClineProvider.upsertProviderProfile).not.toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).toHaveBeenLastCalledWith({ + type: "settingsSaveResult", + requestId: "save", + success: false, + unsavedSettings: ["apiConfiguration"], + }) + }) + + it("ignores an upsertApiConfiguration message without a payload when no requestId is supplied", async () => { + // Legacy callers omit the profile payload and the requestId; the handler must stay a no-op. + await webviewMessageHandler(mockClineProvider, { type: "upsertApiConfiguration" }) + expect(mockClineProvider.upsertProviderProfile).not.toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("defaults the debug setting to false when the value is omitted", async () => { + const update = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ update } as never) + await webviewMessageHandler(mockClineProvider, { type: "debugSetting" }) + expect(update).toHaveBeenCalledWith("debug", false, 1) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) +}) + +describe("webviewMessageHandler - updateSettings branch handling", () => { + let updateConfig: ReturnType + let setValue: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + updateConfig = vi.fn().mockResolvedValue(undefined) + setValue = vi.mocked(mockClineProvider.contextProxy.setValue) + setValue.mockResolvedValue(undefined as never) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn(), update: updateConfig } as never) + ;(mockClineProvider as unknown as { getMcpHub: ReturnType }).getMcpHub = vi + .fn() + .mockReturnValue(undefined) + Terminal.setTerminalProfile(undefined) + }) + + afterEach(() => { + Terminal.setTerminalProfile(undefined) + }) + + it("applies command and file allow/deny lists, dropping blank entries", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "lists", + updatedSettings: { + allowedCommands: ["a", "", " ", "b"], + deniedCommands: ["x", ""], + allowedReadFiles: ["src/**", " "], + allowedWriteFiles: [], + }, + }) + + expect(updateConfig).toHaveBeenCalledWith("allowedCommands", ["a", "b"], 1) + expect(updateConfig).toHaveBeenCalledWith("deniedCommands", ["x"], 1) + expect(setValue).toHaveBeenCalledWith("allowedReadFiles", ["src/**"]) + expect(setValue).toHaveBeenCalledWith("allowedWriteFiles", []) + expect(mockClineProvider.postMessageToWebview).toHaveBeenLastCalledWith({ + type: "settingsSaveResult", + requestId: "lists", + success: true, + unsavedSettings: [], + }) + }) + + it("applies the language, TTS and generic default branches", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "misc", + updatedSettings: { + language: "fr", + ttsEnabled: false, + ttsSpeed: 2.5, + soundEnabled: true, + }, + }) + + expect(vi.mocked(changeLanguage)).toHaveBeenCalledWith("fr") + expect(setValue).toHaveBeenCalledWith("language", "fr") + expect(setValue).toHaveBeenCalledWith("ttsEnabled", false) + expect(setValue).toHaveBeenCalledWith("ttsSpeed", 2.5) + expect(setValue).toHaveBeenCalledWith("soundEnabled", true) + }) + + it("applies every terminal setting branch, including the profile reset that closes idle terminals", async () => { + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "terminal", + updatedSettings: { + terminalShellIntegrationTimeout: 30, + terminalShellIntegrationDisabled: true, + terminalCommandDelay: 75, + terminalPowershellCounter: true, + terminalZshClearEolMark: true, + terminalZshOhMy: true, + terminalZshP10k: true, + terminalZdotdir: true, + terminalProfile: "Git Bash", + execaShellPath: "/bin/bash", + }, + }) + + expect(Terminal.getShellIntegrationTimeout()).toBe(30) + expect(Terminal.getCommandDelay()).toBe(75) + expect(Terminal.getTerminalProfile()).toBe("Git Bash") + expect(closeIdleTerminalsSpy).toHaveBeenCalled() + expect(setValue).toHaveBeenCalledWith("terminalProfile", "Git Bash") + expect(setValue).toHaveBeenCalledWith("execaShellPath", "/bin/bash") + }) + + it("notifies the MCP hub when mcpEnabled changes", async () => { + const handleMcpEnabledChange = vi.fn().mockResolvedValue(undefined) + ;(mockClineProvider as unknown as { getMcpHub: ReturnType }).getMcpHub = vi + .fn() + .mockReturnValue({ handleMcpEnabledChange }) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "mcp", + updatedSettings: { mcpEnabled: false }, + }) + + expect(handleMcpEnabledChange).toHaveBeenCalledWith(false) + expect(setValue).toHaveBeenCalledWith("mcpEnabled", false) + }) + + it("acknowledges unset experiments and customSupportPrompts without persisting them", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "unset", + updatedSettings: { experiments: undefined, customSupportPrompts: undefined }, + }) + + expect(setValue).not.toHaveBeenCalledWith("experiments", expect.anything()) + expect(mockClineProvider.postMessageToWebview).toHaveBeenLastCalledWith({ + type: "settingsSaveResult", + requestId: "unset", + success: true, + unsavedSettings: [], + }) + }) + + it("reports a failed bulk save with requestId and permits a later retry", async () => { + setValue.mockRejectedValueOnce(new Error("storage unavailable")).mockResolvedValue(undefined as never) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "bulk-fail", + updatedSettings: { soundEnabled: true, chatInputEffect: "breathing" }, + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "bulk-fail", + success: false, + unsavedSettings: ["soundEnabled", "chatInputEffect"], + }) + expect(vi.mocked(vscode.window.showErrorMessage)).not.toHaveBeenCalled() + }) + + it("surfaces a legacy bulk save failure without a requestId via showErrorMessage", async () => { + setValue.mockRejectedValueOnce(new Error("disk full")) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { soundEnabled: true, chatInputEffect: "breathing" }, + }) + + expect(vi.mocked(vscode.window.showErrorMessage)).toHaveBeenCalledWith("common:errors.settingsSaveFailed") + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "settingsSaveResult" }), + ) + }) + + it("defaults a null language to en and applies the tts fallbacks", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "defaults", + updatedSettings: { language: undefined, ttsEnabled: undefined, ttsSpeed: undefined }, + }) + + expect(vi.mocked(changeLanguage)).toHaveBeenCalledWith("en") + expect(setValue).toHaveBeenCalledWith("language", "en") + expect(setValue).toHaveBeenCalledWith("ttsEnabled", true) + expect(setValue).toHaveBeenCalledWith("ttsSpeed", 1.0) + }) + + it("drops a non-array command list and a non-array file pattern list", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "non-array", + updatedSettings: { + allowedCommands: "rm -rf /" as unknown as string[], + deniedCommands: "rm -rf /" as unknown as string[], + allowedReadFiles: "src/**" as unknown as string[], + allowedWriteFiles: "src/**" as unknown as string[], + }, + }) + + expect(updateConfig).toHaveBeenCalledWith("allowedCommands", [], 1) + expect(updateConfig).toHaveBeenCalledWith("deniedCommands", [], 1) + expect(setValue).toHaveBeenCalledWith("allowedReadFiles", []) + expect(setValue).toHaveBeenCalledWith("allowedWriteFiles", []) + }) + + it("tolerates a missing command or file list by treating it as empty", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "missing-lists", + updatedSettings: { + allowedCommands: undefined, + deniedCommands: undefined, + allowedReadFiles: undefined, + allowedWriteFiles: undefined, + }, + }) + + expect(updateConfig).toHaveBeenCalledWith("allowedCommands", [], 1) + expect(updateConfig).toHaveBeenCalledWith("deniedCommands", [], 1) + expect(setValue).toHaveBeenCalledWith("allowedReadFiles", []) + expect(setValue).toHaveBeenCalledWith("allowedWriteFiles", []) + }) + + it("leaves terminal settings untouched when their values are undefined", async () => { + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + Terminal.setTerminalProfile(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "terminal-undefined", + updatedSettings: { + terminalShellIntegrationTimeout: undefined, + terminalShellIntegrationDisabled: undefined, + terminalCommandDelay: undefined, + terminalPowershellCounter: undefined, + terminalZshClearEolMark: undefined, + terminalZshOhMy: undefined, + terminalZshP10k: undefined, + terminalZdotdir: undefined, + }, + }) + + // Every terminal branch guards on `value !== undefined`, so the profile-change + // side effect must not fire and the values are stored as-is. + expect(closeIdleTerminalsSpy).not.toHaveBeenCalled() + expect(setValue).toHaveBeenCalledWith("terminalShellIntegrationTimeout", undefined) + }) + + it("treats a non-string terminalProfile as unset and does not close terminal state", async () => { + const closeIdleTerminalsSpy = vi.spyOn(TerminalRegistry, "closeIdleTerminals").mockImplementation(() => {}) + Terminal.setTerminalProfile(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "terminal-nonstring", + updatedSettings: { terminalProfile: 123 as unknown as string }, + }) + + expect(Terminal.getTerminalProfile()).toBeUndefined() + expect(closeIdleTerminalsSpy).not.toHaveBeenCalled() + }) + + it("does not touch the MCP hub when none is available", async () => { + ;(mockClineProvider as unknown as { getMcpHub: ReturnType }).getMcpHub = vi + .fn() + .mockReturnValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "mcp-missing", + updatedSettings: { mcpEnabled: true }, + }) + + expect(setValue).toHaveBeenCalledWith("mcpEnabled", true) + }) + + it("defaults mcpEnabled to true when the value is unset", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "mcp-default", + updatedSettings: { mcpEnabled: undefined }, + }) + + expect(setValue).toHaveBeenCalledWith("mcpEnabled", true) + }) + + it("merges a truthy experiments payload over the stored values or the defaults", async () => { + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => + key === "experiments" ? { customTools: true } : undefined, + ) + setValue.mockClear() + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "experiments-on", + updatedSettings: { experiments: { runSlashCommand: true } }, + }) + expect(setValue).toHaveBeenCalledWith( + "experiments", + expect.objectContaining({ customTools: true, runSlashCommand: true }), + ) + + // With no stored value the default experiment set is used as the base. + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(undefined) + setValue.mockClear() + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "experiments-default", + updatedSettings: { experiments: { parallelToolExecution: true } }, + }) + expect(setValue).toHaveBeenCalledWith("experiments", expect.objectContaining({ parallelToolExecution: true })) + }) + + it("persists a truthy customSupportPrompts payload as-is", async () => { + setValue.mockClear() + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "prompts-on", + updatedSettings: { customSupportPrompts: { review: "Review this" } }, + }) + + expect(setValue).toHaveBeenCalledWith("customSupportPrompts", { review: "Review this" }) + }) + + it("logs Unknown when a bulk save fails with a non-Error value", async () => { + setValue.mockRejectedValueOnce("boom") + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "bulk-nonerror", + updatedSettings: { soundEnabled: true, chatInputEffect: "breathing" }, + }) + + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining('"Unknown"')) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "bulk-nonerror", + success: false, + unsavedSettings: ["soundEnabled", "chatInputEffect"], + }) + }) + + it("logs Unknown when an individual save throws a non-Error value", async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn(), + update: vi.fn().mockRejectedValue("no-permission"), + } as never) + + await webviewMessageHandler(mockClineProvider, { + type: "debugSetting", + requestId: "debug-nonerror", + bool: true, + }) + + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining('"Unknown"')) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "debug-nonerror", + success: false, + unsavedSettings: ["debug"], + }) + }) +}) + +describe("webviewMessageHandler - serialized settings saves", () => { + it.each(["updateSettings", "upsertApiConfiguration", "telemetrySetting", "debugSetting"] as const)( + "cancels queued %s without waiting for blocked storage", + async (type) => { + const controller = new AbortController() + const provider = mockClineProvider + const signalGetter = vi.spyOn(provider, "settingsSaveSignal", "get").mockReturnValue(controller.signal) + try { + let release!: () => void + const write = vi.mocked(provider.contextProxy.setValue).mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }), + ) + const active = webviewMessageHandler(provider, { + type: "updateSettings", + updatedSettings: { soundVolume: 0.2 }, + }) + const pending = webviewMessageHandler(provider, { + type, + requestId: "cancelled", + updatedSettings: { soundVolume: 0.9 }, + }) + const cancellations = Promise.all([ + expect(active).rejects.toMatchObject({ name: "AbortError" }), + expect(pending).rejects.toMatchObject({ name: "AbortError" }), + ]) + controller.abort() + await cancellations + release() + await enqueueSettingsSave(provider.contextProxy, new AbortController().signal, async () => {}) + expect(write).not.toHaveBeenCalledWith("soundVolume", 0.9) + expect(provider.postMessageToWebview).not.toHaveBeenCalledWith( + expect.objectContaining({ requestId: "cancelled" }), + ) + } finally { + signalGetter.mockRestore() + } + }, + ) + + it("keeps processing later saves after an earlier save throws", async () => { + ;(mockClineProvider as unknown as { getMcpHub: () => undefined }).getMcpHub = () => undefined + // debugSetting without a requestId rethrows its write failure, which is what + // exercises the queue's failure swallow. + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn(), + update: vi.fn().mockRejectedValue(new Error("storage unavailable")), + } as never) + + await expect(webviewMessageHandler(mockClineProvider, { type: "debugSetting", bool: true })).rejects.toThrow( + "storage unavailable", + ) + + // The queue stored the guarded version, so a following save still runs to completion. + vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined as never) + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "after-failure", + updatedSettings: { soundVolume: 0.3 }, + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenLastCalledWith({ + type: "settingsSaveResult", + requestId: "after-failure", + success: true, + unsavedSettings: [], + }) + }) +}) diff --git a/src/core/webview/settingsSaveQueue.ts b/src/core/webview/settingsSaveQueue.ts new file mode 100644 index 0000000000..1235baf55c --- /dev/null +++ b/src/core/webview/settingsSaveQueue.ts @@ -0,0 +1,52 @@ +// Serialize writes sharing a ContextProxy, but keep waiting jobs removable on disposal. +// VS Code persistence has no cancellation API: an active write must settle before +// another view can write to the same context, even after its caller is cancelled. +type SaveJob = { run: () => Promise } +type SaveQueue = { pending: Set; running: boolean } +const queues = new WeakMap() + +export function enqueueSettingsSave(context: object, signal: AbortSignal, write: () => Promise): Promise { + signal.throwIfAborted() + let queue = queues.get(context) + if (!queue) { + queue = { pending: new Set(), running: false } + queues.set(context, queue) + } + const currentQueue = queue + return new Promise((resolve, reject) => { + const onAbort = () => { + currentQueue.pending.delete(job) + signal.removeEventListener("abort", onAbort) + reject(signal.reason) + } + const job: SaveJob = { + run: async () => { + try { + signal.throwIfAborted() + await write() + resolve() + } catch (error) { + reject(error) + } finally { + signal.removeEventListener("abort", onAbort) + } + }, + } + signal.addEventListener("abort", onAbort, { once: true }) + currentQueue.pending.add(job) + void drain(context, currentQueue) + }) +} + +async function drain(context: object, queue: SaveQueue): Promise { + if (queue.running) return + queue.running = true + try { + for (const job of queue.pending) { + queue.pending.delete(job) + await job.run() + } + } finally { + queues.delete(context) + } +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 193540455b..19110735dd 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -39,7 +39,7 @@ import { type ApiMessage } from "../task-persistence/apiMessages" import { saveTaskMessages } from "../task-persistence" import { importRooTaskHistory } from "../task-persistence/importRooTaskHistory" -import { ClineProvider } from "./ClineProvider" +import type { ClineProvider } from "./ClineProvider" import { findOriginalContent } from "./stripOriginalContent" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" import { generateErrorDiagnostics } from "./diagnosticsHandler" @@ -119,15 +119,69 @@ import { handleCheckoutBranch, } from "./worktree" +import { enqueueSettingsSave } from "./settingsSaveQueue" + +// A webview timeout does not cancel host writes. Keep every SettingsView save message +// in arrival order, including retries and messages from views sharing the same context. +const settingsSaveMessageTypes = new Set([ + "updateSettings", + "upsertApiConfiguration", + "telemetrySetting", + "debugSetting", +]) + export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, marketplaceManager?: MarketplaceManager, +): Promise => { + if (!settingsSaveMessageTypes.has(message.type)) { + return handleWebviewMessage(provider, message, marketplaceManager) + } + + const signal = provider.settingsSaveSignal + return enqueueSettingsSave(provider.contextProxy, signal, () => + handleWebviewMessage(provider, message, marketplaceManager, signal), + ) +} + +const handleWebviewMessage = async ( + provider: ClineProvider, + message: WebviewMessage, + marketplaceManager?: MarketplaceManager, + signal?: AbortSignal, ) => { // Utility functions provided for concise get/update of global state via contextProxy API. const getGlobalState = (key: K) => provider.contextProxy.getValue(key) - const updateGlobalState = async (key: K, value: GlobalState[K]) => + const updateGlobalState = async (key: K, value: GlobalState[K]) => { + signal?.throwIfAborted() await provider.contextProxy.setValue(key, value) + } + + // SettingsView correlates each write separately before marking the whole save complete. + const saveSetting = async (key: string, write: () => Promise) => { + let success = false + try { + signal?.throwIfAborted() + await write() + signal?.throwIfAborted() + success = true + } catch (error) { + signal?.throwIfAborted() + if (!message.requestId) throw error + provider.log( + `Failed to save settings: ${JSON.stringify([key])}; error: ${JSON.stringify(error instanceof Error ? error.name : "Unknown")}`, + ) + } + if (message.requestId) { + await provider.postMessageToWebview({ + type: "settingsSaveResult", + requestId: message.requestId, + success, + unsavedSettings: success ? [] : [key], + }) + } + } const getCurrentCwd = () => { return provider.getCurrentTask()?.cwd || provider.cwd @@ -580,7 +634,68 @@ export const webviewMessageHandler = async ( provider.resolveWebviewThemeFixtureProbe(message.requestId, message.themeFixture) } break - case "webviewDidLaunch": + case "webviewDidLaunch": { + // A disposed view may still be completing an uncancellable profile write. + // Refresh metadata before publishing state, including after a failed save. + const launchSignal = provider.settingsSaveSignal + let refreshTimedOut = false + const refreshProfiles = enqueueSettingsSave(provider.contextProxy, launchSignal, async () => { + let metadata = await provider.providerSettingsManager.listConfig() + let repairedProfile = false + launchSignal.throwIfAborted() + if (metadata) { + if (metadata.length === 1 && !checkExistKey(metadata[0])) { + const { apiConfiguration } = await provider.getState() + launchSignal.throwIfAborted() + + // Only sync a first-run profile when the current configuration has meaningful settings. + if (checkExistKey(apiConfiguration)) { + await provider.providerSettingsManager.saveConfig( + metadata[0].name ?? "default", + apiConfiguration, + ) + launchSignal.throwIfAborted() + metadata = await provider.providerSettingsManager.listConfig() + launchSignal.throwIfAborted() + } + } + + const currentConfigName = getGlobalState("currentApiConfigName") + if (currentConfigName) { + const hasConfig = await provider.providerSettingsManager.hasConfig(currentConfigName) + launchSignal.throwIfAborted() + if (!hasConfig && metadata[0]?.name) { + await provider.repairProviderProfile(metadata[0].name, metadata, launchSignal) + repairedProfile = true + } + } + } + launchSignal.throwIfAborted() + if (!repairedProfile) await updateGlobalState("listApiConfigMeta", metadata) + launchSignal.throwIfAborted() + if (metadata) await provider.postMessageToWebview({ type: "listApiConfig", listApiConfig: metadata }) + launchSignal.throwIfAborted() + if (refreshTimedOut) await provider.postStateToWebview() + }).catch((error) => { + if (launchSignal.aborted && error === launchSignal.reason) throw error + provider.log("Failed to refresh provider profile metadata on launch") + }) + let refreshTimeout: ReturnType | undefined + try { + await Promise.race([ + refreshProfiles, + new Promise((resolve) => { + refreshTimeout = setTimeout(() => { + // Release only the launch wait; keep the write queue serialized. + refreshTimedOut = true + provider.log("Provider profile metadata refresh timed out on launch") + resolve() + }, 30_000) + }), + ]) + } finally { + clearTimeout(refreshTimeout) + } // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) @@ -602,59 +717,6 @@ export const webviewMessageHandler = async ( await provider.postMessageToWebview({ type: "mcpServers", mcpServers: mcpHub.getAllServers() }) } - provider.providerSettingsManager - .listConfig() - .then(async (listApiConfig) => { - if (!listApiConfig) { - return - } - - if (listApiConfig.length === 1) { - // Check if first time init then sync with exist config. - if (!checkExistKey(listApiConfig[0])) { - const { apiConfiguration } = await provider.getState() - - // Only save if the current configuration has meaningful settings - // (e.g., API keys). This prevents saving a default "anthropic" - // fallback when no real config exists, which can happen during - // CLI initialization before provider settings are applied. - if (checkExistKey(apiConfiguration)) { - await provider.providerSettingsManager.saveConfig( - listApiConfig[0].name ?? "default", - apiConfiguration, - ) - - listApiConfig[0].apiProvider = apiConfiguration.apiProvider - } - } - } - - const currentConfigName = getGlobalState("currentApiConfigName") - - if (currentConfigName) { - if (!(await provider.providerSettingsManager.hasConfig(currentConfigName))) { - // Current config name not valid, get first config in list. - const name = listApiConfig[0]?.name - await updateGlobalState("currentApiConfigName", name) - - if (name) { - await provider.activateProviderProfile({ name }) - return - } - } - } - - await Promise.all([ - await updateGlobalState("listApiConfigMeta", listApiConfig), - await provider.postMessageToWebview({ type: "listApiConfig", listApiConfig }), - ]) - }) - .catch((error) => - provider.log( - `Error list api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, - ), - ) - // Telemetry is on by disclosed default: "unset" (no choice made yet) leaves that // default in effect, same as "enabled". Only an explicit "disabled" opts out. // vscode.env.isTelemetryEnabled is ANDed in (matching extension.ts's @@ -689,6 +751,7 @@ export const webviewMessageHandler = async ( provider.isViewLaunched = true break + } case "newTask": // Initializing new instance of Cline will make sure that any // agentically running promises in old instance don't affect our new @@ -731,12 +794,15 @@ export const webviewMessageHandler = async ( if (message.updatedSettings.destructiveCommandGuardEnabled === true) { try { const { ensureDcgInstalled } = await import("../../services/destructive-command-guard") + signal?.throwIfAborted() const binaryPath = await ensureDcgInstalled(provider.context.globalStorageUri.fsPath) + signal?.throwIfAborted() if (!binaryPath) { message.updatedSettings.destructiveCommandGuardEnabled = false vscode.window.showErrorMessage(t("common:errors.destructiveCommandGuard.unavailable")) } } catch (error) { + signal?.throwIfAborted() message.updatedSettings.destructiveCommandGuardEnabled = false vscode.window.showErrorMessage( t("common:errors.destructiveCommandGuard.enableFailed", { @@ -746,117 +812,146 @@ export const webviewMessageHandler = async ( } } - for (const [key, value] of Object.entries(message.updatedSettings)) { - let newValue = value - - if (key === "language") { - newValue = value ?? "en" - changeLanguage(newValue as Language) - } else if (key === "allowedCommands") { - const commands = value ?? [] - - newValue = Array.isArray(commands) - ? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) - : [] - - await vscode.workspace - .getConfiguration(Package.name) - .update("allowedCommands", newValue, vscode.ConfigurationTarget.Global) - } else if (key === "deniedCommands") { - const commands = value ?? [] - - newValue = Array.isArray(commands) - ? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) - : [] - - await vscode.workspace - .getConfiguration(Package.name) - .update("deniedCommands", newValue, vscode.ConfigurationTarget.Global) - } else if (key === "allowedReadFiles" || key === "allowedWriteFiles") { - const patterns = value ?? [] - - // Blank lines, which the textarea editor produces freely, - // name no file and are dropped here. Patterns are - // otherwise not `.trim()`ed: leading whitespace is - // significant in gitignore syntax, and trailing - // whitespace has to be escaped by the user to be kept. - newValue = Array.isArray(patterns) - ? patterns.filter((pattern) => typeof pattern === "string" && pattern.trim().length > 0) - : [] - } else if (key === "ttsEnabled") { - newValue = value ?? true - setTtsEnabled(newValue as boolean) - } else if (key === "ttsSpeed") { - newValue = value ?? 1.0 - setTtsSpeed(newValue as number) - } else if (key === "terminalShellIntegrationTimeout") { - if (value !== undefined) { - Terminal.setShellIntegrationTimeout(value as number) - } - } else if (key === "terminalShellIntegrationDisabled") { - if (value !== undefined) { - Terminal.setShellIntegrationDisabled(value as boolean) - } - } else if (key === "terminalCommandDelay") { - if (value !== undefined) { - Terminal.setCommandDelay(value as number) - } - } else if (key === "terminalPowershellCounter") { - if (value !== undefined) { - Terminal.setPowershellCounter(value as boolean) - } - } else if (key === "terminalZshClearEolMark") { - if (value !== undefined) { - Terminal.setTerminalZshClearEolMark(value as boolean) - } - } else if (key === "terminalZshOhMy") { - if (value !== undefined) { - Terminal.setTerminalZshOhMy(value as boolean) - } - } else if (key === "terminalZshP10k") { - if (value !== undefined) { - Terminal.setTerminalZshP10k(value as boolean) - } - } else if (key === "terminalZdotdir") { - if (value !== undefined) { - Terminal.setTerminalZdotdir(value as boolean) - } - } else if (key === "terminalProfile") { - const previousProfile = Terminal.getTerminalProfile() - Terminal.setTerminalProfile(typeof value === "string" ? value : undefined) - newValue = Terminal.getTerminalProfile() - - if (newValue !== previousProfile) { - // Discard idle terminals so the next command gets a fresh - // terminal using the new profile's shell instead of reusing - // a stale one from the previous profile. - TerminalRegistry.closeIdleTerminals() - } - } else if (key === "execaShellPath") { - Terminal.setExecaShellPath(value as string | undefined) - } else if (key === "mcpEnabled") { - newValue = value ?? true - const mcpHub = provider.getMcpHub() - - if (mcpHub) { - await mcpHub.handleMcpEnabledChange(newValue as boolean) - } - } else if (key === "experiments") { - if (!value) { - continue - } + const pendingSettings = new Set(Object.keys(message.updatedSettings)) + try { + for (const [key, value] of Object.entries(message.updatedSettings)) { + signal?.throwIfAborted() + let newValue = value + + if (key === "language") { + newValue = value ?? "en" + changeLanguage(newValue as Language) + } else if (key === "allowedCommands") { + const commands = value ?? [] + + newValue = Array.isArray(commands) + ? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) + : [] - newValue = { - ...(getGlobalState("experiments") ?? experimentDefault), - ...(value as Record), - } - } else if (key === "customSupportPrompts") { - if (!value) { - continue + await vscode.workspace + .getConfiguration(Package.name) + .update("allowedCommands", newValue, vscode.ConfigurationTarget.Global) + } else if (key === "deniedCommands") { + const commands = value ?? [] + + newValue = Array.isArray(commands) + ? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) + : [] + + await vscode.workspace + .getConfiguration(Package.name) + .update("deniedCommands", newValue, vscode.ConfigurationTarget.Global) + } else if (key === "allowedReadFiles" || key === "allowedWriteFiles") { + const patterns = value ?? [] + + // Blank lines, which the textarea editor produces freely, + // name no file and are dropped here. Patterns are + // otherwise not `.trim()`ed: leading whitespace is + // significant in gitignore syntax, and trailing + // whitespace has to be escaped by the user to be kept. + newValue = Array.isArray(patterns) + ? patterns.filter((pattern) => typeof pattern === "string" && pattern.trim().length > 0) + : [] + } else if (key === "ttsEnabled") { + newValue = value ?? true + setTtsEnabled(newValue as boolean) + } else if (key === "ttsSpeed") { + newValue = value ?? 1.0 + setTtsSpeed(newValue as number) + } else if (key === "terminalShellIntegrationTimeout") { + if (value !== undefined) { + Terminal.setShellIntegrationTimeout(value as number) + } + } else if (key === "terminalShellIntegrationDisabled") { + if (value !== undefined) { + Terminal.setShellIntegrationDisabled(value as boolean) + } + } else if (key === "terminalCommandDelay") { + if (value !== undefined) { + Terminal.setCommandDelay(value as number) + } + } else if (key === "terminalPowershellCounter") { + if (value !== undefined) { + Terminal.setPowershellCounter(value as boolean) + } + } else if (key === "terminalZshClearEolMark") { + if (value !== undefined) { + Terminal.setTerminalZshClearEolMark(value as boolean) + } + } else if (key === "terminalZshOhMy") { + if (value !== undefined) { + Terminal.setTerminalZshOhMy(value as boolean) + } + } else if (key === "terminalZshP10k") { + if (value !== undefined) { + Terminal.setTerminalZshP10k(value as boolean) + } + } else if (key === "terminalZdotdir") { + if (value !== undefined) { + Terminal.setTerminalZdotdir(value as boolean) + } + } else if (key === "terminalProfile") { + const previousProfile = Terminal.getTerminalProfile() + Terminal.setTerminalProfile(typeof value === "string" ? value : undefined) + newValue = Terminal.getTerminalProfile() + + if (newValue !== previousProfile) { + // Discard idle terminals so the next command gets a fresh + // terminal using the new profile's shell instead of reusing + // a stale one from the previous profile. + TerminalRegistry.closeIdleTerminals() + } + } else if (key === "execaShellPath") { + Terminal.setExecaShellPath(value as string | undefined) + } else if (key === "mcpEnabled") { + newValue = value ?? true + const mcpHub = provider.getMcpHub() + + if (mcpHub) { + await mcpHub.handleMcpEnabledChange(newValue as boolean) + } + } else if (key === "experiments") { + if (!value) { + pendingSettings.delete(key) + continue + } + + newValue = { + ...(getGlobalState("experiments") ?? experimentDefault), + ...(value as Record), + } + } else if (key === "customSupportPrompts") { + if (!value) { + pendingSettings.delete(key) + continue + } } - } - await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) + signal?.throwIfAborted() + await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) + signal?.throwIfAborted() + pendingSettings.delete(key) + } + } catch (error) { + signal?.throwIfAborted() + // Earlier entries may already be saved. Keep the remaining keys retryable. + // Escape untrusted keys and error names; values and error text may contain secrets. + provider.log( + `Failed to save settings: ${JSON.stringify([...pendingSettings])}; error: ${JSON.stringify(error instanceof Error ? error.name : "Unknown")}`, + ) + if (!message.requestId) { + void vscode.window.showErrorMessage( + t("common:errors.settingsSaveFailed", { keys: [...pendingSettings].join(", ") }), + ) + } + } + if (message.requestId) { + await provider.postMessageToWebview({ + type: "settingsSaveResult", + requestId: message.requestId, + success: pendingSettings.size === 0, + unsavedSettings: [...pendingSettings], + }) } await provider.postStateToWebview() @@ -2303,9 +2398,20 @@ export const webviewMessageHandler = async ( } break case "upsertApiConfiguration": - if (message.text && message.apiConfiguration) { - await provider.upsertProviderProfile(message.text, message.apiConfiguration) - } + await saveSetting("apiConfiguration", async () => { + if (message.text && message.apiConfiguration) { + const id = await provider.upsertProviderProfile( + message.text, + message.apiConfiguration, + true, + signal, + ) + // The provider returns undefined when persistence fails. + if (message.requestId && id === undefined) throw new Error() + } else if (message.requestId) { + throw new Error() + } + }) break case "renameApiConfiguration": if (message.values && message.apiConfiguration) { @@ -2753,6 +2859,7 @@ export const webviewMessageHandler = async ( const thisUpdate = telemetrySettingQueue .catch(() => undefined) .then(async () => { + signal?.throwIfAborted() const telemetrySetting = message.text as TelemetrySetting const previousSetting = getGlobalState("telemetrySetting") || "unset" const isOptedIn = isTelemetryOptedIn(telemetrySetting) @@ -2769,6 +2876,7 @@ export const webviewMessageHandler = async ( // captureTelemetrySettingsChanged calls above/below still track the user's // stored preference transition on its own, independent of that live toggle. await updateGlobalState("telemetrySetting", telemetrySetting) + signal?.throwIfAborted() if (TelemetryService.hasInstance()) { TelemetryService.instance.updateTelemetryState(isOptedIn && vscode.env.isTelemetryEnabled) @@ -2783,14 +2891,17 @@ export const webviewMessageHandler = async ( }) telemetrySettingQueue = thisUpdate - await thisUpdate + await saveSetting("telemetrySetting", () => thisUpdate) break } case "debugSetting": { - await vscode.workspace - .getConfiguration(Package.name) - .update("debug", message.bool ?? false, vscode.ConfigurationTarget.Global) - await provider.postStateToWebview() + await saveSetting("debug", async () => { + await vscode.workspace + .getConfiguration(Package.name) + .update("debug", message.bool ?? false, vscode.ConfigurationTarget.Global) + signal?.throwIfAborted() + await provider.postStateToWebview() + }) break } case "rooCloudSignIn": { diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index a85c19cec4..e1a71a1165 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -151,7 +151,8 @@ "manual_url_auth_failed": "Autenticació manual per URL ha fallat", "manual_url_auth_error": "Autenticació fallida", "mode_import_failed": "Ha fallat la importació del mode: {{error}}", - "rooHistoryImport": "No s'ha pogut importar l'historial de Roo Code: {{error}}" + "rooHistoryImport": "No s'ha pogut importar l'historial de Roo Code: {{error}}", + "settingsSaveFailed": "No s’han pogut desar alguns paràmetres. Seleccioneu Desa per tornar-ho a provar. {{keys}}" }, "warnings": { "no_terminal_content": "No s'ha seleccionat contingut de terminal", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 9f2b6d2056..5a5cdcfb2b 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -147,7 +147,8 @@ "manual_url_missing_params": "Ungültige Callback-URL: erforderliche Parameter (code und state) fehlen", "manual_url_auth_failed": "Manuelle URL-Authentifizierung fehlgeschlagen", "manual_url_auth_error": "Authentifizierung fehlgeschlagen", - "rooHistoryImport": "Fehler beim Importieren des Roo Code-Verlaufs: {{error}}" + "rooHistoryImport": "Fehler beim Importieren des Roo Code-Verlaufs: {{error}}", + "settingsSaveFailed": "Einige Einstellungen konnten nicht gespeichert werden. Wählen Sie Speichern, um es erneut zu versuchen. {{keys}}" }, "warnings": { "no_terminal_content": "Kein Terminal-Inhalt ausgewählt", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 507780366a..61d7bcbbd3 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -147,7 +147,8 @@ "manual_url_no_query": "Invalid callback URL: missing query parameters", "manual_url_missing_params": "Invalid callback URL: missing required parameters (code and state)", "manual_url_auth_failed": "Manual URL authentication failed", - "manual_url_auth_error": "Authentication failed" + "manual_url_auth_error": "Authentication failed", + "settingsSaveFailed": "Some settings could not be saved. Select Save to retry. {{keys}}" }, "warnings": { "no_terminal_content": "No terminal content selected", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index f646f5c4f3..3d94e79eb9 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Error al importar el historial de Roo Code: {{error}}" + "rooHistoryImport": "Error al importar el historial de Roo Code: {{error}}", + "settingsSaveFailed": "No se pudieron guardar algunos ajustes. Selecciona Guardar para volver a intentarlo. {{keys}}" }, "warnings": { "no_terminal_content": "No hay contenido de terminal seleccionado", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 0e5f59ca1e..dd49f789fa 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Échec de l'importation de l'historique Roo Code : {{error}}" + "rooHistoryImport": "Échec de l'importation de l'historique Roo Code : {{error}}", + "settingsSaveFailed": "Certains paramètres n’ont pas pu être enregistrés. Sélectionnez Enregistrer pour réessayer. {{keys}}" }, "warnings": { "no_terminal_content": "Aucun contenu de terminal sélectionné", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 59cf0f0657..2527ab845d 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Roo Code इतिहास आयात करने में विफल: {{error}}" + "rooHistoryImport": "Roo Code इतिहास आयात करने में विफल: {{error}}", + "settingsSaveFailed": "कुछ सेटिंग्स सहेजी नहीं जा सकीं। फिर से प्रयास करने के लिए सहेजें चुनें। {{keys}}" }, "warnings": { "no_terminal_content": "कोई टर्मिनल सामग्री चयनित नहीं", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index 87ce78d209..67c7a76af6 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Gagal mengimpor riwayat Roo Code: {{error}}" + "rooHistoryImport": "Gagal mengimpor riwayat Roo Code: {{error}}", + "settingsSaveFailed": "Beberapa pengaturan tidak dapat disimpan. Pilih Simpan untuk mencoba lagi. {{keys}}" }, "warnings": { "no_terminal_content": "Tidak ada konten terminal yang dipilih", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index ce7ffce090..d426bc4812 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Impossibile importare la cronologia di Roo Code: {{error}}" + "rooHistoryImport": "Impossibile importare la cronologia di Roo Code: {{error}}", + "settingsSaveFailed": "Impossibile salvare alcune impostazioni. Seleziona Salva per riprovare. {{keys}}" }, "warnings": { "no_terminal_content": "Nessun contenuto del terminale selezionato", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index d412e8f8e0..21b54d6fde 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Roo Codeの履歴のインポートに失敗しました: {{error}}" + "rooHistoryImport": "Roo Codeの履歴のインポートに失敗しました: {{error}}", + "settingsSaveFailed": "一部の設定を保存できませんでした。「保存」を選択して再試行してください。 {{keys}}" }, "warnings": { "no_terminal_content": "選択されたターミナルコンテンツがありません", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index ff2fcb10d9..64126639ea 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Roo Code 기록 가져오기 실패: {{error}}" + "rooHistoryImport": "Roo Code 기록 가져오기 실패: {{error}}", + "settingsSaveFailed": "일부 설정을 저장하지 못했습니다. 저장을 선택하여 다시 시도하세요. {{keys}}" }, "warnings": { "no_terminal_content": "선택된 터미널 내용이 없습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index e8b639af5d..596f3129a1 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Importeren van Roo Code-geschiedenis mislukt: {{error}}" + "rooHistoryImport": "Importeren van Roo Code-geschiedenis mislukt: {{error}}", + "settingsSaveFailed": "Sommige instellingen konden niet worden opgeslagen. Selecteer Opslaan om het opnieuw te proberen. {{keys}}" }, "warnings": { "no_terminal_content": "Geen terminalinhoud geselecteerd", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 73e2293a10..3ba460414a 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Nie udało się zaimportować historii Roo Code: {{error}}" + "rooHistoryImport": "Nie udało się zaimportować historii Roo Code: {{error}}", + "settingsSaveFailed": "Nie udało się zapisać niektórych ustawień. Wybierz Zapisz, aby spróbować ponownie. {{keys}}" }, "warnings": { "no_terminal_content": "Nie wybrano zawartości terminala", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 82654929b4..eda473ef8e 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -151,7 +151,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Falha ao importar o histórico do Roo Code: {{error}}" + "rooHistoryImport": "Falha ao importar o histórico do Roo Code: {{error}}", + "settingsSaveFailed": "Não foi possível salvar algumas configurações. Selecione Salvar para tentar novamente. {{keys}}" }, "warnings": { "no_terminal_content": "Nenhum conteúdo do terminal selecionado", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 5857b1290f..36ce745840 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Ошибка импорта истории Roo Code: {{error}}" + "rooHistoryImport": "Ошибка импорта истории Roo Code: {{error}}", + "settingsSaveFailed": "Не удалось сохранить некоторые настройки. Нажмите «Сохранить», чтобы повторить попытку. {{keys}}" }, "warnings": { "no_terminal_content": "Не выбрано содержимое терминала", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 48faf7d03c..e221883bcc 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Roo Code geçmişi içe aktarılamadı: {{error}}" + "rooHistoryImport": "Roo Code geçmişi içe aktarılamadı: {{error}}", + "settingsSaveFailed": "Bazı ayarlar kaydedilemedi. Tekrar denemek için Kaydet’i seçin. {{keys}}" }, "warnings": { "no_terminal_content": "Seçili terminal içeriği yok", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 5651e0eb3b..276c8c91a7 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "Không thể nhập lịch sử Roo Code: {{error}}" + "rooHistoryImport": "Không thể nhập lịch sử Roo Code: {{error}}", + "settingsSaveFailed": "Không thể lưu một số cài đặt. Chọn Lưu để thử lại. {{keys}}" }, "warnings": { "no_terminal_content": "Không có nội dung terminal được chọn", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index e5ecf86da2..f5ef2880d7 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -152,7 +152,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "导入 Roo Code 历史记录失败:{{error}}" + "rooHistoryImport": "导入 Roo Code 历史记录失败:{{error}}", + "settingsSaveFailed": "部分设置未能保存。请选择“保存”重试。 {{keys}}" }, "warnings": { "no_terminal_content": "没有选择终端内容", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index e70c464da0..05f02b237c 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -147,7 +147,8 @@ "unexpectedStreamError": "Unexpected error processing response stream", "completionError": "OpenAI Codex completion error: {{message}}" }, - "rooHistoryImport": "匯入 Roo Code 歷史記錄失敗:{{error}}" + "rooHistoryImport": "匯入 Roo Code 歷史記錄失敗:{{error}}", + "settingsSaveFailed": "部分設定無法儲存。請選擇「儲存」重試。 {{keys}}" }, "warnings": { "no_terminal_content": "沒有選擇終端機內容", diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 17c53f40d4..098f6325da 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -124,9 +124,18 @@ export const stories: Record = { await import("@/components/settings/__tests__/AutoApproveSettings.visual.fixture") return }, - "chat-text-area": async () => { + "chat-text-area": async ({ isStreaming, chatInputEffect }) => { const { ChatTextAreaStory } = await import("@/components/chat/__tests__/ChatTextArea.visual.fixture") - return + return ( + + ) + }, + "chat-effects": async () => { + const { ChatEffectsStory } = await import("@/components/chat/__tests__/ChatEffects.visual.fixture") + return }, "history-empty": async () => { const [{ AppProviders }, { default: HistoryView }] = await Promise.all([ diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index a78f3f0f8e..88f8cffe50 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -103,6 +103,7 @@ export const ChatTextArea = forwardRef( clineMessages, commands, enterBehavior, + chatInputEffect, lockApiConfigAcrossModes, } = useExtensionState() @@ -1028,6 +1029,36 @@ export const ChatTextArea = forwardRef( "overflow-hidden", "rounded-lg", )}> + {/* Streaming border effect — marquee (conic-gradient light beam) or breathing (pulsing glow + 1px ring). + Selected via Settings → UI → chatInputEffect. Only shown while AI is executing. */} + {!isDraggingOver && isStreaming && (chatInputEffect ?? "marquee") === "marquee" && ( +
+
+
+ )} + {/* Breathing border effect — pulsing glow + 1px blue ring. No overflow-hidden needed (box-shadow would be clipped). */} + {!isDraggingOver && isStreaming && chatInputEffect === "breathing" && ( +
+
+
+
+ )}
( ? "border border-vscode-focusBorder outline outline-vscode-focusBorder" : isDraggingOver ? "border-2 border-dashed border-vscode-focusBorder" - : "border border-transparent", + : "border border-vscode-input-border", "pl-2", "py-2", isEditMode ? "pr-20" : "pr-9", @@ -1110,7 +1141,7 @@ export const ChatTextArea = forwardRef( ? "border border-vscode-focusBorder outline outline-vscode-focusBorder" : isDraggingOver ? "border-2 border-dashed border-vscode-focusBorder" - : "border border-transparent", + : "border border-vscode-input-border", isDraggingOver ? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]" : "bg-vscode-input-background", diff --git a/webview-ui/src/components/chat/Markdown.tsx b/webview-ui/src/components/chat/Markdown.tsx index 87780d5df8..cbddb666ee 100644 --- a/webview-ui/src/components/chat/Markdown.tsx +++ b/webview-ui/src/components/chat/Markdown.tsx @@ -2,11 +2,13 @@ import { memo, useState } from "react" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { useCopyToClipboard } from "@src/utils/clipboard" +import { useExtensionState } from "@src/context/ExtensionStateContext" import { StandardTooltip } from "@src/components/ui" import MarkdownBlock from "../common/MarkdownBlock" export const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boolean }) => { + const { tableStriped } = useExtensionState() const [isHovering, setIsHovering] = useState(false) // Shorter feedback duration for copy button flash. @@ -22,7 +24,7 @@ export const Markdown = memo(({ markdown, partial }: { markdown?: string; partia onMouseLeave={() => setIsHovering(false)} style={{ position: "relative" }}>
- +
{markdown && !partial && isHovering && (
{ const { t } = useTranslation() - const { reasoningBlockCollapsed } = useExtensionState() + const { reasoningBlockCollapsed, tableStriped } = useExtensionState() const [isCollapsed, setIsCollapsed] = useState(reasoningBlockCollapsed) @@ -51,7 +51,13 @@ export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockP onClick={handleToggle}>
- {t("chat:reasoning.thinking")} + + {t("chat:reasoning.thinking")} + {elapsed > 0 && ( {secondsLabel} )} @@ -69,7 +75,7 @@ export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockP
- +
)}
diff --git a/webview-ui/src/components/chat/__tests__/ChatEffects.visual.fixture.tsx b/webview-ui/src/components/chat/__tests__/ChatEffects.visual.fixture.tsx new file mode 100644 index 0000000000..be1e23f935 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatEffects.visual.fixture.tsx @@ -0,0 +1,24 @@ +import React from "react" +import { AppProviders } from "../../../../playwright/AppProviders" +import MarkdownBlock from "../../common/MarkdownBlock" +import { ReasoningBlock } from "../ReasoningBlock" + +const table = `| File | Status | +| --- | --- | +| Composer | Updated | +| Reasoning | Streaming | +| Markdown | Striped | +| Settings | Saved |` + +export function ChatEffectsStory() { + return ( + +
+ + +
+
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatEffects.visual.tsx b/webview-ui/src/components/chat/__tests__/ChatEffects.visual.tsx new file mode 100644 index 0000000000..8860a4bab5 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatEffects.visual.tsx @@ -0,0 +1,32 @@ +import { expect, test } from "../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../playwright/mounted-story" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" + +for (const theme of visualThemes) { + test(`streaming reasoning and striped markdown in ${theme.name}`, async ({ mount, page }) => { + await page.clock.setFixedTime(new Date("2026-01-01T00:00:00Z")) + const component = mountedStory(await mount("chat-effects")) + await applyVisualTheme(page, theme) + const story = component.getByTestId("chat-effects-story") + const label = story.locator(".animate-thinking-shine") + await expect(label).toHaveCSS("animation-name", "thinking-shine") + await label.evaluate((element) => { + for (const animation of element.getAnimations()) { + animation.pause() + // At 500 ms the highlight band crosses the middle of the label. + animation.currentTime = 500 + } + }) + await expect(story.getByRole("table")).toBeVisible() + const rows = story.locator("tbody tr") + const firstBackground = await rows.nth(0).evaluate((element) => getComputedStyle(element).backgroundColor) + await expect(rows.nth(1)).not.toHaveCSS("background-color", firstBackground) + await expect(story).toHaveScreenshot(`chat-effects-${theme.name}.png`, { animations: "allow" }) + + await page.emulateMedia({ reducedMotion: "reduce" }) + await expect(label).toHaveCSS("animation-name", "none") + await expect(label).toHaveCSS("background-image", "none") + const foreground = await story.evaluate((element) => getComputedStyle(element).color) + await expect(label).toHaveCSS("-webkit-text-fill-color", foreground) + }) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx index 5cced6a92f..c86df835e7 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx @@ -1529,4 +1529,129 @@ describe("ChatTextArea", () => { expect(defaultProps.setInputValue).toHaveBeenCalledWith("abc/some/path def") }) }) + + describe("streaming border animation", () => { + it.each(["marquee", "breathing"] as const)( + "hides the %s overlay during drag-over and restores it when dragging ends", + (chatInputEffect) => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + taskHistory: [], + cwd: "/test/workspace", + chatInputEffect, + }) + render() + const textarea = screen.getByRole("textbox") + expect(screen.getByTestId("streaming-border")).toBeInTheDocument() + + // JSDOM has no DragEvent; MouseEvent preserves the modifier and pointer coordinates. + fireEvent( + textarea, + Object.assign(new MouseEvent("dragover", { bubbles: true, cancelable: true, shiftKey: true }), { + dataTransfer: { dropEffect: "none" }, + }), + ) + expect(screen.queryByTestId("streaming-border")).not.toBeInTheDocument() + + fireEvent(textarea, new MouseEvent("dragleave", { bubbles: true, clientX: 0, clientY: 0 })) + expect(screen.getByTestId("streaming-border")).toBeInTheDocument() + }, + ) + + it("renders marquee border overlay only when AI is streaming", () => { + const { container } = render() + + const borderDiv = container.querySelector('[data-testid="streaming-border"]') + expect(borderDiv).toBeInTheDocument() + // Marquee effect renders a border-spin ring + expect(borderDiv!.querySelector('[class*="border-spin"]')).toBeInTheDocument() + }) + + it("does not render border overlay when AI is not streaming", () => { + const { container } = render() + + const borderDiv = container.querySelector('[data-testid="streaming-border"]') + expect(borderDiv).not.toBeInTheDocument() + }) + + it("does not render the breathing border overlay while idle", () => { + vi.mocked(useExtensionState).mockReturnValue({ + ...useExtensionState(), + chatInputEffect: "breathing", + }) + render() + + expect(screen.queryByTestId("streaming-border")).not.toBeInTheDocument() + }) + + it("renders marquee border overlay while streaming even when focused", () => { + const { container } = render() + + const textarea = container.querySelector("textarea")! + fireEvent.focus(textarea) + + const borderDiv = container.querySelector('[data-testid="streaming-border"]') + expect(borderDiv).toBeInTheDocument() + }) + + it("renders breathing border overlay when chatInputEffect is breathing", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + }, + taskHistory: [], + cwd: "/test/workspace", + chatInputEffect: "breathing", + }) + const { container } = render() + + const borderDiv = container.querySelector('[data-testid="streaming-border"]') + expect(borderDiv).toBeInTheDocument() + // Breathing effect renders streaming-glow + border-breathe classes + expect(borderDiv!.querySelector('[class*="streaming-glow"]')).toBeInTheDocument() + expect(borderDiv!.querySelector('[class*="border-breathe"]')).toBeInTheDocument() + }) + + it("does not render breathing border when chatInputEffect is marquee", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + }, + taskHistory: [], + cwd: "/test/workspace", + chatInputEffect: "marquee", + }) + const { container } = render() + + const borderDiv = container.querySelector('[data-testid="streaming-border"]') + expect(borderDiv).toBeInTheDocument() + // Marquee effect should NOT have breathing classes + expect(borderDiv!.querySelector('[class*="streaming-glow"]')).not.toBeInTheDocument() + expect(borderDiv!.querySelector('[class*="border-breathe"]')).not.toBeInTheDocument() + }) + }) + + describe("input border state", () => { + it("keeps a shallow border on the textarea when it is not focused (not transparent)", () => { + const { container } = render() + + const textarea = container.querySelector("textarea")! + expect(textarea).toHaveClass("border-vscode-input-border") + expect(textarea).not.toHaveClass("border-transparent") + }) + + it("shows the focus border on the textarea when it is focused", () => { + const { container } = render() + + const textarea = container.querySelector("textarea")! + fireEvent.focus(textarea) + expect(textarea).toHaveClass("border-vscode-focusBorder") + }) + }) }) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx index 7585881f14..7f1295b218 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx @@ -4,17 +4,24 @@ import { defaultModeSlug, type Mode } from "@roo/modes" import { AppProviders } from "../../../../playwright/AppProviders" import { ChatTextArea } from "../ChatTextArea" -export function ChatTextAreaStory() { +export function ChatTextAreaStory({ + isStreaming = false, + chatInputEffect = "marquee", +}: { + isStreaming?: boolean + chatInputEffect?: "marquee" | "breathing" +}) { const [inputValue, setInputValue] = useState("Audit contrast across the Zoo Code webview") const [selectedImages, setSelectedImages] = useState([]) const [mode, setMode] = useState(defaultModeSlug) return ( - +
(document.activeElement as HTMLElement | null)?.blur()) @@ -30,3 +32,33 @@ for (const theme of visualThemes) { }) }) } + +for (const theme of visualThemes) { + for (const chatInputEffect of ["marquee", "breathing"] as const) { + test(`streaming composer ${chatInputEffect} in ${theme.name}`, async ({ mount, page }) => { + const component = mountedStory(await mount("chat-text-area", { isStreaming: true, chatInputEffect })) + await applyVisualTheme(page, theme) + const story = component.getByTestId("chat-text-area-story") + await story.getByRole("textbox").blur() + const effects = story.getByTestId("streaming-border").locator(":scope > div") + await expect(effects.first()).toHaveCSS( + "animation-name", + chatInputEffect === "marquee" ? "border-spin" : "streaming-glow", + ) + // Preserve a visible, deterministic point in each production animation. + await effects.evaluateAll((elements) => { + for (const element of elements) { + for (const animation of element.getAnimations()) { + animation.pause() + animation.currentTime = 1000 + } + } + }) + await expect(story).toHaveScreenshot(`chat-composer-${chatInputEffect}-${theme.name}.png`, { + animations: "allow", + }) + await page.emulateMedia({ reducedMotion: "reduce" }) + for (const effect of await effects.all()) await expect(effect).toHaveCSS("animation-name", "none") + }) + } +} diff --git a/webview-ui/src/components/chat/__tests__/Markdown.spec.tsx b/webview-ui/src/components/chat/__tests__/Markdown.spec.tsx new file mode 100644 index 0000000000..f7d18314bc --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/Markdown.spec.tsx @@ -0,0 +1,29 @@ +import { renderWithExtensionState } from "@/utils/test-utils" + +import MarkdownBlock from "../../common/MarkdownBlock" +import { Markdown } from "../Markdown" + +vi.mock("../../common/MarkdownBlock", () => ({ + default: vi.fn(() => null), +})) + +describe("Markdown table striping", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + { tableStriped: true, expectedStriped: true }, + { tableStriped: false, expectedStriped: false }, + // Unset keeps the legacy striped appearance. + { tableStriped: undefined, expectedStriped: undefined }, + ])( + "passes striped=$expectedStriped when extension state tableStriped=$tableStriped", + ({ tableStriped, expectedStriped }) => { + const markdown = "| Name | Value |\n| --- | --- |\n| Setting | Saved |" + renderWithExtensionState(, { state: { tableStriped } }) + + expect(vi.mocked(MarkdownBlock).mock.lastCall?.[0]).toMatchObject({ markdown, striped: expectedStriped }) + }, + ) +}) diff --git a/webview-ui/src/components/chat/__tests__/ReasoningBlock.spec.tsx b/webview-ui/src/components/chat/__tests__/ReasoningBlock.spec.tsx new file mode 100644 index 0000000000..e0f0ffef7f --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ReasoningBlock.spec.tsx @@ -0,0 +1,91 @@ +import React from "react" +import { renderWithExtensionState, screen } from "@/utils/test-utils" + +import MarkdownBlock from "../../common/MarkdownBlock" +import { ReasoningBlock } from "../ReasoningBlock" + +// Isolate the label behavior and the props passed to the markdown renderer. +vi.mock("../../common/MarkdownBlock", () => ({ + default: vi.fn(({ markdown }: { markdown: string; striped?: boolean }) => ( +
{markdown}
+ )), +})) + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { count?: number }) => { + if (key === "chat:reasoning.thinking") { + return "Thinking..." + } + if (key === "chat:reasoning.seconds" && options?.count !== undefined) { + return `${options.count}s` + } + return key + }, + }), +})) + +const renderReasoningBlock = (props: Partial> = {}) => { + return renderWithExtensionState( + , + { state: { reasoningBlockCollapsed: false } }, + ) +} + +describe("ReasoningBlock thinking shimmer", () => { + it("applies the shimmer animation to the latest streaming block", () => { + renderReasoningBlock({ isStreaming: true, isLast: true }) + + const label = screen.getByText("Thinking...") + expect(label).toHaveClass("animate-thinking-shine") + expect(label).not.toHaveClass("text-vscode-foreground") + }) + + it("keeps historical reasoning blocks static while another block is streaming", () => { + renderReasoningBlock({ isStreaming: true, isLast: false }) + + const label = screen.getByText("Thinking...") + expect(label).not.toHaveClass("animate-thinking-shine") + expect(label).toHaveClass("text-vscode-foreground") + }) + + it("keeps completed reasoning blocks static once streaming has finished", () => { + renderReasoningBlock({ isStreaming: false, isLast: true }) + + const label = screen.getByText("Thinking...") + expect(label).not.toHaveClass("animate-thinking-shine") + expect(label).toHaveClass("text-vscode-foreground") + }) + + it("always keeps the bold styling on the thinking label", () => { + renderReasoningBlock({ isStreaming: true, isLast: true }) + + expect(screen.getByText("Thinking...")).toHaveClass("font-bold") + }) +}) + +describe("ReasoningBlock table striping", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + { tableStriped: true, expectedStriped: true }, + { tableStriped: false, expectedStriped: false }, + // Unset keeps the legacy striped appearance. + { tableStriped: undefined, expectedStriped: undefined }, + ])( + "passes striped=$expectedStriped when extension state tableStriped=$tableStriped", + ({ tableStriped, expectedStriped }) => { + const content = "| Name | Value |\n| --- | --- |\n| Setting | Saved |" + renderWithExtensionState(, { + state: { reasoningBlockCollapsed: false, tableStriped }, + }) + + expect(vi.mocked(MarkdownBlock).mock.lastCall?.[0]).toMatchObject({ + markdown: content, + striped: expectedStriped, + }) + }, + ) +}) diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-breathing-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-breathing-dark.png new file mode 100644 index 0000000000..214d7bb8e2 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-breathing-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-breathing-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-breathing-high-contrast-light.png new file mode 100644 index 0000000000..848d0c879b Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-breathing-high-contrast-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-breathing-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-breathing-high-contrast.png new file mode 100644 index 0000000000..96d5162b46 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-breathing-high-contrast.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-breathing-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-breathing-light.png new file mode 100644 index 0000000000..f4cbca7d46 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-breathing-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-marquee-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-marquee-dark.png new file mode 100644 index 0000000000..d6262d85bf Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-marquee-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-marquee-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-marquee-high-contrast-light.png new file mode 100644 index 0000000000..e2d1528173 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-marquee-high-contrast-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-marquee-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-marquee-high-contrast.png new file mode 100644 index 0000000000..50508855a1 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-marquee-high-contrast.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-marquee-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-marquee-light.png new file mode 100644 index 0000000000..ef578c04c5 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-marquee-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png index fad9aa30b9..e3adfecc06 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png index 465ffc0263..46328c8802 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-high-contrast.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png index 1f238a4619..32d26d8f2a 100644 Binary files a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-resting-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-effects-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-effects-dark.png new file mode 100644 index 0000000000..bfdc7e6c58 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-effects-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-effects-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-effects-high-contrast-light.png new file mode 100644 index 0000000000..cc9d0f671d Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-effects-high-contrast-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-effects-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-effects-high-contrast.png new file mode 100644 index 0000000000..8fecd6e252 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-effects-high-contrast.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-effects-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-effects-light.png new file mode 100644 index 0000000000..6d249987ef Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-effects-light.png differ diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 02f696553f..c12cf03f8b 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -32,6 +32,11 @@ const ALERT_LABELS: Record = { interface MarkdownBlockProps { markdown?: string + /** + * Zebra striping for markdown tables (Settings → UI → tableStriped). + * Defaults to enabled so an unset setting keeps the legacy striped appearance. + */ + striped?: boolean } const StyledMarkdown = styled.div` @@ -213,7 +218,9 @@ const StyledMarkdown = styled.div` color: var(--vscode-foreground); } - tr:nth-child(even) { + /* Opt-in zebra striping (Settings → UI). The ampersand keeps the class on + this element; without it styled-components would require a descendant class. */ + &.table-striped tr:nth-child(even) { background-color: var(--vscode-editor-inactiveSelectionBackground); } @@ -273,7 +280,7 @@ const StyledMarkdown = styled.div` } ` -const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { +const MarkdownBlock = memo(({ markdown, striped = true }: MarkdownBlockProps) => { const components = useMemo( () => ({ table: ({ children, ...props }: any) => { @@ -395,7 +402,7 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { ) return ( - + { expect(screen.getByText("Third level ordered")).toBeInTheDocument() expect(screen.getByText("Back to first level")).toBeInTheDocument() }) + + it("should apply table striping by default", () => { + // Unset keeps the legacy striped appearance. + const { container } = render() + + const root = container.firstElementChild as HTMLElement + expect(root).toHaveClass("table-striped") + }) + + it("should not apply table striping when the striped prop is disabled", () => { + const { container } = render() + + const root = container.firstElementChild as HTMLElement + expect(root).not.toHaveClass("table-striped") + }) }) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 61ba954dd5..2183ab0df1 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -32,6 +32,7 @@ import { } from "lucide-react" import { + type ExtensionMessage, type ProviderSettings, type ExperimentId, type TelemetrySetting, @@ -152,6 +153,50 @@ const SettingsView = forwardRef(({ onDone, t const confirmDialogHandler = useRef<() => void>() const [cachedState, setCachedState] = useState(() => extensionState) + const pendingSave = useRef<{ + requests: Map + unsavedSettings: string[] + state: ExtensionStateContextType + timeoutId: ReturnType + }>() + const [saveError, setSaveError] = useState() + const [isSaving, setIsSaving] = useState(false) + + useEffect(() => { + return () => { + if (pendingSave.current) { + clearTimeout(pendingSave.current.timeoutId) + pendingSave.current = undefined + } + } + }, []) + + useEffect(() => { + const handleSaveResult = (event: MessageEvent) => { + const message = event.data + const pending = pendingSave.current + if (message.type !== "settingsSaveResult" || !pending || !message.requestId) return + const setting = pending.requests.get(message.requestId) + if (!setting) return + pending.requests.delete(message.requestId) + if (!message.success) { + pending.unsavedSettings.push(...(message.unsavedSettings?.length ? message.unsavedSettings : [setting])) + } + if (pending.requests.size > 0) return + clearTimeout(pending.timeoutId) + pendingSave.current = undefined + setIsSaving(false) + if (pending.unsavedSettings.length === 0) { + setSaveError(undefined) + if (cachedState === pending.state) setChangeDetected(false) + } else { + setSaveError(pending.unsavedSettings) + setChangeDetected(true) + } + } + window.addEventListener("message", handleSaveResult) + return () => window.removeEventListener("message", handleSaveResult) + }, [cachedState]) const { alwaysAllowReadOnly, @@ -214,6 +259,8 @@ const SettingsView = forwardRef(({ onDone, t reasoningBlockCollapsed, chatFontSize, enterBehavior, + chatInputEffect, + tableStriped, includeCurrentTime, includeCurrentCost, maxGitStatusFiles, @@ -380,9 +427,34 @@ const SettingsView = forwardRef(({ onDone, t const isSettingValid = !errorMessage const handleSubmit = () => { - if (isSettingValid) { + if (isSettingValid && !pendingSave.current) { + const requestId = crypto.randomUUID() + const apiRequestId = crypto.randomUUID() + const telemetryRequestId = crypto.randomUUID() + const debugRequestId = crypto.randomUUID() + pendingSave.current = { + requests: new Map([ + [requestId, "updateSettings"], + [apiRequestId, "apiConfiguration"], + [telemetryRequestId, "telemetrySetting"], + [debugRequestId, "debug"], + ]), + unsavedSettings: [], + state: cachedState, + timeoutId: setTimeout(() => { + const pending = pendingSave.current + if (!pending) return + pendingSave.current = undefined + setIsSaving(false) + setSaveError([...pending.unsavedSettings, ...pending.requests.values()]) + setChangeDetected(true) + }, 30_000), + } + setIsSaving(true) + setSaveError(undefined) vscode.postMessage({ type: "updateSettings", + requestId, updatedSettings: { language, alwaysAllowReadOnly: alwaysAllowReadOnly ?? undefined, @@ -441,6 +513,8 @@ const SettingsView = forwardRef(({ onDone, t reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, chatFontSize: chatFontSize ?? null, enterBehavior: enterBehavior ?? "send", + chatInputEffect: chatInputEffect ?? "marquee", + tableStriped, includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, @@ -459,11 +533,14 @@ const SettingsView = forwardRef(({ onDone, t // These have more complex logic so they aren't (yet) handled // by the `updateSettings` message. - vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration }) - vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting }) - vscode.postMessage({ type: "debugSetting", bool: cachedState.debug }) - - setChangeDetected(false) + vscode.postMessage({ + type: "upsertApiConfiguration", + requestId: apiRequestId, + text: currentApiConfigName, + apiConfiguration, + }) + vscode.postMessage({ type: "telemetrySetting", requestId: telemetryRequestId, text: telemetrySetting }) + vscode.postMessage({ type: "debugSetting", requestId: debugRequestId, bool: cachedState.debug }) } } @@ -694,7 +771,7 @@ const SettingsView = forwardRef(({ onDone, t variant={isSettingValid ? "primary" : "secondary"} className={!isSettingValid ? "!border-vscode-errorForeground" : ""} onClick={handleSubmit} - disabled={!isChangeDetected || !isSettingValid} + disabled={isSaving || !isChangeDetected || !isSettingValid} data-testid="save-button"> {t("settings:common.save")} @@ -702,6 +779,12 @@ const SettingsView = forwardRef(({ onDone, t
+ {saveError && ( +
+ {t("settings:common.saveFailed")} {saveError.join(", ")} +
+ )} + {/* Vertical tabs layout */}
{/* Tab sidebar */} @@ -940,7 +1023,9 @@ const SettingsView = forwardRef(({ onDone, t { reasoningBlockCollapsed: boolean enterBehavior: "send" | "newline" + chatInputEffect: "marquee" | "breathing" chatFontSize?: number + tableStriped?: boolean autoCloseZooOpenedFiles?: boolean autoCloseZooOpenedFilesAfterUserEdited?: boolean autoCloseZooOpenedNewFiles?: boolean @@ -32,7 +34,9 @@ interface UISettingsProps extends HTMLAttributes { export const UISettings = ({ reasoningBlockCollapsed, enterBehavior, + chatInputEffect, chatFontSize, + tableStriped, autoCloseZooOpenedFiles, autoCloseZooOpenedFilesAfterUserEdited, autoCloseZooOpenedNewFiles, @@ -82,6 +86,25 @@ export const UISettings = ({ telemetryClient.capture("ui_settings_chat_font_size_reset") } + // VSCodeDropdown's onChange is typed as an intersection of a native Event + // handler and a FormEventHandler, so we accept `unknown` (assignable to both) + // and narrow to the actual ) => onChange?.(e)} + {...props}> + {children} + + ), + VSCodeOption: ({ children, value, ...props }: ComponentProps<"option">) => ( + + ), })) vi.mock("../../../components/common/Tab", () => ({ @@ -190,8 +212,13 @@ vi.mock("@/components/ui", () => ({ data-testid={dataTestId} /> ), - Button: ({ children, onClick, variant, className, "data-testid": dataTestId }: any) => ( - ), @@ -334,7 +361,7 @@ const renderSettingsView = (initialState: any = {}) => { // Helper to get elements within the settings content (not the indexing container) const getSettingsContent = () => screen.getByTestId("settings-content") - return { onDone, activateTab, getSettingsContent } + return { onDone, activateTab, getSettingsContent, unmount: result.unmount } } describe("SettingsView - Sound Settings", () => { @@ -447,6 +474,16 @@ describe("SettingsView - Sound Settings", () => { }), ) + for (const [request] of vi.mocked(vscode.postMessage).mock.calls.slice(-4)) { + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "settingsSaveResult", requestId: request.requestId, success: true }, + }), + ), + ) + } + // Reset clears the override; it is persisted as null (not undefined). fireEvent.click(within(getSettingsContent()).getByTestId("chat-font-size-reset")) fireEvent.click(screen.getByTestId("save-button")) @@ -1008,3 +1045,353 @@ describe("SettingsView - openAiStrictToolSchemas save round trip", () => { expect(posted()?.apiConfiguration?.openAiStrictToolSchemas).toBeUndefined() }) }) + +describe("SettingsView - Chat Appearance Save Payload", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("saves the selected chat input effect and table striping", () => { + renderWithExtensionState(, { + state: makeExtensionState({ chatInputEffect: "marquee", tableStriped: false }), + }) + const content = within(screen.getByTestId("settings-content")) + + fireEvent.change(content.getByTestId("chat-input-effect-dropdown"), { target: { value: "breathing" } }) + fireEvent.click(content.getByTestId("table-striped-checkbox")) + + expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "updateSettings" })) + fireEvent.click(screen.getByTestId("save-button")) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ chatInputEffect: "breathing", tableStriped: true }), + }), + ) + }) + + it("saves false when table striping is turned off", () => { + renderWithExtensionState(, { + state: makeExtensionState({ tableStriped: true }), + }) + const content = within(screen.getByTestId("settings-content")) + + fireEvent.click(content.getByTestId("table-striped-checkbox")) + fireEvent.click(screen.getByTestId("save-button")) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ tableStriped: false }), + }), + ) + }) + + it("saves marquee and preserves unset table striping when the settings are unset", () => { + renderWithExtensionState(, { + state: makeExtensionState({ chatInputEffect: undefined, tableStriped: undefined }), + }) + const content = within(screen.getByTestId("settings-content")) + + // Make an unrelated edit so Save is enabled while both appearance settings remain unset. + fireEvent.change(content.getByTestId("chat-font-size-slider"), { target: { value: "18" } }) + fireEvent.click(screen.getByTestId("save-button")) + + const update = vi + .mocked(vscode.postMessage) + .mock.calls.map(([message]) => message) + .find((message) => message.type === "updateSettings") + expect(update?.updatedSettings).toEqual(expect.objectContaining({ chatInputEffect: "marquee" })) + // `undefined` keeps the legacy striped default instead of persisting `false`. + expect(update?.updatedSettings?.tableStriped).toBeUndefined() + }) +}) + +describe("settings save acknowledgment", () => { + function beginSave() { + const view = renderSettingsView() + view.activateTab("notifications") + const checkbox = within(view.getSettingsContent()).getByTestId("tts-enabled-checkbox") + fireEvent.click(checkbox) + const save = screen.getByTestId("save-button") + fireEvent.click(save) + const request = vi + .mocked(vscode.postMessage) + .mock.calls.filter(([message]) => message.type === "updateSettings") + .at(-1)?.[0] + expect(request?.requestId).toBeDefined() + return { save, checkbox, requestId: request?.requestId, onDone: view.onDone, unmount: view.unmount } + } + + function respond(requestId: string | undefined, success: boolean) { + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "settingsSaveResult", + requestId, + success, + unsavedSettings: success ? [] : ["chatInputEffect", "tableStriped"], + }, + }), + ), + ) + } + + function respondToOtherWrites() { + for (const [request] of vi.mocked(vscode.postMessage).mock.calls.slice(-3)) { + respond(request.requestId, true) + } + } + + it.each(["updateSettings", "upsertApiConfiguration", "telemetrySetting", "debugSetting"])( + "waits for every write and retains dirty state when %s fails", + (failedType) => { + const { save } = beginSave() + const requests = vi + .mocked(vscode.postMessage) + .mock.calls.slice(-4) + .map(([message]) => message) + expect(new Set(requests.map((request) => request.requestId)).size).toBe(4) + const failed = requests.find((request) => request.type === failedType)! + // Complete in reverse order, leaving the failure until last. + for (const request of [...requests].reverse().filter((request) => request !== failed)) { + respond(request.requestId, true) + expect(save).toBeDisabled() + respond(request.requestId, true) // Duplicate acknowledgements cannot complete a save. + expect(save).toBeDisabled() + } + respond(failed.requestId, false) + expect(save).toBeEnabled() + expect(screen.getByRole("alert")).toBeInTheDocument() + }, + ) + + it("clears dirty state only after all four writes succeed", () => { + const { save, requestId, onDone } = beginSave() + respond(requestId, true) + expect(save).toBeDisabled() + respondToOtherWrites() + expect(save).toBeDisabled() + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "settings:common.done" })) + expect(onDone).toHaveBeenCalledTimes(1) + }) + + it("retains dirty edits on partial failure and clears them only after a successful retry", () => { + const { save, checkbox, requestId } = beginSave() + expect(save).toBeDisabled() + respond("unrelated", true) + expect(save).toBeDisabled() + respond(requestId, false) + respondToOtherWrites() + expect(screen.getByRole("alert")).toHaveTextContent("chatInputEffect, tableStriped") + expect(checkbox).toBeChecked() + expect(save).toBeEnabled() + fireEvent.click(save) + const retry = vi + .mocked(vscode.postMessage) + .mock.calls.filter(([message]) => message.type === "updateSettings") + .at(-1)?.[0] + expect(retry?.requestId).not.toBe(requestId) + respond(requestId, false) + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + respond(retry?.requestId, true) + respondToOtherWrites() + expect(save).toBeDisabled() + }) + + it("keeps newer edits dirty when an earlier save succeeds", () => { + const { save, checkbox, requestId } = beginSave() + fireEvent.click(checkbox) + respond(requestId, true) + respondToOtherWrites() + expect(checkbox).not.toBeChecked() + expect(save).toBeEnabled() + }) + + describe("save timeout", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it("reports missing results after 30 seconds and ignores late results during a retry", () => { + const { save, checkbox } = beginSave() + const expiredRequests = vi + .mocked(vscode.postMessage) + .mock.calls.slice(-4) + .map(([message]) => message) + act(() => vi.advanceTimersByTime(29_999)) + expect(save).toBeDisabled() + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + act(() => vi.advanceTimersByTime(1)) + expect(screen.getByRole("alert")).toHaveTextContent( + "updateSettings, apiConfiguration, telemetrySetting, debug", + ) + expect(checkbox).toBeChecked() + expect(save).toBeEnabled() + + fireEvent.click(save) + for (const request of expiredRequests) respond(request.requestId, true) + expect(save).toBeDisabled() + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + for (const [request] of vi.mocked(vscode.postMessage).mock.calls.slice(-4)) respond(request.requestId, true) + act(() => vi.advanceTimersByTime(30_000)) + expect(save).toBeDisabled() + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + }) + + it("preserves failures and reports only outstanding requests even after another edit", () => { + const { save, checkbox, requestId } = beginSave() + respond(requestId, false) + const requests = vi + .mocked(vscode.postMessage) + .mock.calls.slice(-4) + .map(([message]) => message) + for (const request of requests.slice(2)) respond(request.requestId, true) + fireEvent.click(checkbox) + act(() => vi.advanceTimersByTime(30_000)) + expect(screen.getByRole("alert")).toHaveTextContent("chatInputEffect, tableStriped, apiConfiguration") + expect(screen.getByRole("alert")).not.toHaveTextContent("telemetrySetting") + expect(screen.getByRole("alert")).not.toHaveTextContent("debug") + expect(save).toBeEnabled() + }) + + it.each([true, false])("clears the timeout when all results arrive (success: %s)", (success) => { + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout") + const { save, requestId } = beginSave() + clearTimeoutSpy.mockClear() + respond(requestId, success) + respondToOtherWrites() + expect(clearTimeoutSpy).toHaveBeenCalledTimes(1) + act(() => vi.advanceTimersByTime(30_000)) + if (success) { + expect(save).toBeDisabled() + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + } else { + expect(save).toBeEnabled() + expect(screen.getByRole("alert")).toHaveTextContent("chatInputEffect, tableStriped") + } + }) + + it("clears the timeout on unmount", () => { + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout") + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout") + const addEventListenerSpy = vi.spyOn(window, "addEventListener") + const removeEventListenerSpy = vi.spyOn(window, "removeEventListener") + const { unmount, requestId } = beginSave() + const handleSaveResult = addEventListenerSpy.mock.calls + .filter( + ([type, listener]) => + type === "message" && typeof listener === "function" && listener.name === "handleSaveResult", + ) + .at(-1)?.[1] + expect(handleSaveResult).toBeDefined() + const timerIndex = setTimeoutSpy.mock.calls.findIndex(([, delay]) => delay === 30_000) + expect(timerIndex).toBeGreaterThanOrEqual(0) + const timeoutId = setTimeoutSpy.mock.results[timerIndex].value + clearTimeoutSpy.mockClear() + removeEventListenerSpy.mockClear() + unmount() + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId) + expect(removeEventListenerSpy).toHaveBeenCalledWith("message", handleSaveResult) + respond(requestId, true) + act(() => vi.advanceTimersByTime(30_000)) + }) + + it("ignores a stale timeout callback once a later save completes", () => { + const { save, requestId } = beginSave() + respond(requestId, true) + respondToOtherWrites() + // Completing the save clears pendingSave, so the leaked timeout must be a no-op. + act(() => vi.advanceTimersByTime(30_000)) + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + expect(save).toBeDisabled() + }) + }) + + it("falls back to the tracked setting name when a failed result omits its unsaved keys", () => { + const { save, requestId } = beginSave() + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "settingsSaveResult", requestId, success: false, unsavedSettings: [] }, + }), + ), + ) + respondToOtherWrites() + expect(save).toBeEnabled() + expect(screen.getByRole("alert")).toHaveTextContent("updateSettings") + }) + + describe("save button gating", () => { + it("stays disabled until an edit is made, then re-disables while a save is in flight", () => { + vi.mocked(vscode.postMessage).mockClear() + const view = renderSettingsView() + view.activateTab("notifications") + const save = screen.getByTestId("save-button") + // No edits yet: nothing to save. + expect(save).toBeDisabled() + fireEvent.click(within(view.getSettingsContent()).getByTestId("tts-enabled-checkbox")) + expect(save).toBeEnabled() + fireEvent.click(save) + // A second click while the save is pending is a no-op and leaves it disabled. + expect(save).toBeDisabled() + fireEvent.click(save) + const updateRequests = vi + .mocked(vscode.postMessage) + .mock.calls.filter(([message]) => message.type === "updateSettings") + expect(updateRequests).toHaveLength(1) + }) + }) +}) + +describe("SettingsView - save payload fallbacks", () => { + it("persists unset command and file lists as empty arrays", () => { + vi.mocked(vscode.postMessage).mockClear() + const view = renderSettingsView({ + allowedCommands: undefined, + deniedCommands: undefined, + allowedReadFiles: undefined, + allowedWriteFiles: undefined, + }) + view.activateTab("autoApprove") + fireEvent.click(within(view.getSettingsContent()).getByTestId("always-allow-execute-toggle")) + fireEvent.click(screen.getByTestId("save-button")) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ + allowedCommands: [], + deniedCommands: [], + allowedReadFiles: [], + allowedWriteFiles: [], + }), + }), + ) + }) + + it("unmounting without a pending save is a no-op", () => { + const addEventListenerSpy = vi.spyOn(window, "addEventListener") + const removeEventListenerSpy = vi.spyOn(window, "removeEventListener") + const view = renderSettingsView() + const handleSaveResult = addEventListenerSpy.mock.calls + .filter( + ([type, listener]) => + type === "message" && typeof listener === "function" && listener.name === "handleSaveResult", + ) + .at(-1)?.[1] + // No save has been started, so pendingSave.current is undefined on unmount. + view.unmount() + expect(removeEventListenerSpy).toHaveBeenCalledWith("message", handleSaveResult) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 575da5a833..440b075aa3 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -674,6 +674,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { expect(postMessage).toHaveBeenCalledWith({ type: "upsertApiConfiguration", + requestId: expect.any(String), text: "default", apiConfiguration: { apiProvider: providerIdentifiers.nanogpt, @@ -726,6 +727,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { fireEvent.click(screen.getByTestId("save-button")) expect(postMessage).toHaveBeenCalledWith({ type: "upsertApiConfiguration", + requestId: expect.any(String), text: "default", apiConfiguration: { ...configuration, diff --git a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx index 4e61f0fcff..8e59bdf8f7 100644 --- a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx @@ -11,6 +11,7 @@ describe("UISettings", () => { const defaultProps = { reasoningBlockCollapsed: false, enterBehavior: "send" as const, + chatInputEffect: "marquee" as const, setCachedStateField: vi.fn(), } @@ -176,4 +177,64 @@ describe("UISettings", () => { }) }) }) + + describe("chat input effect", () => { + it("renders the chat input effect dropdown", () => { + const { getByTestId } = render() + const dropdown = getByTestId("chat-input-effect-dropdown") as HTMLSelectElement + expect(dropdown).toBeTruthy() + expect(dropdown.value).toBe("marquee") + }) + + it("calls setCachedStateField when chat input effect is changed", async () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render() + + const dropdown = getByTestId("chat-input-effect-dropdown") + fireEvent.change(dropdown, { target: { value: "breathing" } }) + + await waitFor(() => { + expect(setCachedStateField).toHaveBeenCalledWith("chatInputEffect", "breathing") + }) + }) + + it("ignores an empty chat input effect value", () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render() + + const dropdown = getByTestId("chat-input-effect-dropdown") + fireEvent.change(dropdown, { target: { value: "" } }) + + expect(setCachedStateField).not.toHaveBeenCalledWith("chatInputEffect", expect.anything()) + }) + }) + + describe("table striping", () => { + it.each([true, false])("reflects the explicit tableStriped=%s prop", (tableStriped) => { + const { getByTestId } = render() + const checkbox = getByTestId("table-striped-checkbox") as HTMLInputElement + expect(checkbox.checked).toBe(tableStriped) + }) + + it("renders the table striping checkbox checked by default", () => { + // Unset keeps the legacy striped appearance, so the control reflects it as on. + const { getByTestId } = render() + const checkbox = getByTestId("table-striped-checkbox") as HTMLInputElement + expect(checkbox).toBeTruthy() + expect(checkbox.checked).toBe(true) + }) + + it("calls setCachedStateField when the table striping checkbox is toggled", async () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render( + , + ) + + fireEvent.click(getByTestId("table-striped-checkbox")) + + await waitFor(() => { + expect(setCachedStateField).toHaveBeenCalledWith("tableStriped", true) + }) + }) + }) }) diff --git a/webview-ui/src/components/settings/__tests__/UISettings.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/UISettings.visual.fixture.tsx index 09481ce498..8f2d6cdf15 100644 --- a/webview-ui/src/components/settings/__tests__/UISettings.visual.fixture.tsx +++ b/webview-ui/src/components/settings/__tests__/UISettings.visual.fixture.tsx @@ -8,7 +8,9 @@ import { AppProviders } from "../../../../playwright/AppProviders" interface UIState { reasoningBlockCollapsed: boolean enterBehavior: "send" | "newline" + chatInputEffect: "marquee" | "breathing" chatFontSize?: number + tableStriped?: boolean autoCloseZooOpenedFiles?: boolean autoCloseZooOpenedFilesAfterUserEdited?: boolean autoCloseZooOpenedNewFiles?: boolean @@ -18,7 +20,10 @@ export function UISettingsStory() { const [state, setState] = useState({ reasoningBlockCollapsed: true, enterBehavior: "send", + chatInputEffect: "marquee", chatFontSize: 14, + // Pinned so the rendered control is deterministic regardless of the striped default. + tableStriped: false, autoCloseZooOpenedFiles: true, autoCloseZooOpenedFilesAfterUserEdited: true, autoCloseZooOpenedNewFiles: false, @@ -33,6 +38,8 @@ export function UISettingsStory() { return { ...current, [field]: Boolean(value) } case "enterBehavior": return { ...current, enterBehavior: value === "newline" ? "newline" : "send" } + case "chatInputEffect": + return { ...current, chatInputEffect: value === "breathing" ? "breathing" : "marquee" } case "chatFontSize": return { ...current, chatFontSize: typeof value === "number" ? value : undefined } default: diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-dark.png index 2773ec6614..4f15277345 100644 Binary files a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-dark.png and b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast-light.png index ba27c7b912..5aff7a3cc2 100644 Binary files a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast-light.png and b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast-light.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast.png b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast.png index 2eb48a95dc..2dfc24889b 100644 Binary files a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast.png and b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-high-contrast.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-light.png index f454bb2d51..16a618a66d 100644 Binary files a/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-light.png and b/webview-ui/src/components/settings/__tests__/__screenshots__/ui-settings-light.png differ diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index b1445d0ec4..f7c383aa70 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -7,7 +7,8 @@ "reset": "Restablir", "select": "Seleccionar", "add": "Afegir capçalera", - "remove": "Eliminar" + "remove": "Eliminar", + "saveFailed": "No s’han pogut desar alguns paràmetres. Seleccioneu Desar per tornar-ho a provar." }, "search": { "placeholder": "Cercar configuració...", @@ -1133,6 +1134,12 @@ "label": "Requereix {{primaryMod}}+Intro per enviar missatges", "description": "Quan estigui activat, has de prémer {{primaryMod}}+Intro per enviar missatges en lloc de només Intro" }, + "chatInputEffect": { + "label": "Efecte de la caixa d'entrada mentre l'IA treballa", + "marquee": "Efecte marquesina (vora giratòria)", + "breathing": "Efecte de respiració (brillantor pulsant)", + "description": "Tria l'efecte d'animació de la vora de la caixa d'entrada mentre l'IA treballa" + }, "chatFontSize": { "label": "Mida de lletra del xat", "description": "Defineix la mida de la lletra (en píxels) del xat de Zoo Code. Deixa-ho al valor per defecte per coincidir amb la mida de lletra del VS Code.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Tanca automaticament els fitxers nous creats", "description": "Quan esta activat, les pestanyes dels fitxers que Zoo ha creat (en lloc de modificar) tambe es tanquen automaticament despres d'acceptar el canvi." + }, + "tableStriped": { + "label": "Files de taula ratllades", + "description": "Aplica colors de fons alterns a les files de les taules de markdown" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index ae27940b9e..5582e17fba 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -7,7 +7,8 @@ "reset": "Zurücksetzen", "select": "Auswählen", "add": "Header hinzufügen", - "remove": "Entfernen" + "remove": "Entfernen", + "saveFailed": "Einige Einstellungen konnten nicht gespeichert werden. Wählen Sie Speichern, um es erneut zu versuchen." }, "search": { "placeholder": "Einstellungen durchsuchen...", @@ -1133,6 +1134,12 @@ "label": "{{primaryMod}}+Enter zum Senden erfordern", "description": "Wenn aktiviert, musst du {{primaryMod}}+Enter drücken, um Nachrichten zu senden, anstatt nur Enter" }, + "chatInputEffect": { + "label": "Effekt für das Eingabefeld, während die KI arbeitet", + "marquee": "Laufschrift (rotierender Rand)", + "breathing": "Atmung (pulsierendes Leuchten)", + "description": "Wähle den Rahmenanimationseffekt für das Eingabefeld, während die KI arbeitet" + }, "chatFontSize": { "label": "Chat-Schriftgröße", "description": "Lege die Schriftgröße (in Pixeln) für den Zoo Code-Chat fest. Belasse den Standardwert, um die Schriftgröße von VS Code zu übernehmen.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Neu erstellte Dateien automatisch schließen", "description": "Wenn aktiviert, werden Tabs für Dateien, die Zoo neu erstellt hat (anstatt sie zu bearbeiten), nach dem Akzeptieren der Änderung ebenfalls automatisch geschlossen." + }, + "tableStriped": { + "label": "Gestreifte Tabellenzeilen", + "description": "Abwechselnde Hintergrundfarben auf Zeilen in Markdown-Tabellen anwenden" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index b1e2740353..7f1863f339 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -7,7 +7,8 @@ "reset": "Reset", "select": "Select", "add": "Add Header", - "remove": "Remove" + "remove": "Remove", + "saveFailed": "Some settings could not be saved. Select Save to retry." }, "header": { "title": "Settings", @@ -226,6 +227,12 @@ "label": "Require {{primaryMod}}+Enter to send messages", "description": "When enabled, you must press {{primaryMod}}+Enter to send messages instead of just Enter" }, + "chatInputEffect": { + "label": "AI Working Input Box Effect", + "marquee": "Marquee (rotating border)", + "breathing": "Breathing (pulsing glow)", + "description": "Choose the border animation effect on the chat input box while AI is working" + }, "chatFontSize": { "label": "Chat font size", "description": "Set the font size (in pixels) for the Zoo Code chat. Leave at the default to match VS Code's font size.", @@ -242,6 +249,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Auto-close newly created files", "description": "When enabled, tabs for files that Zoo created (rather than modified) will also be closed automatically after the edit is accepted." + }, + "tableStriped": { + "label": "Striped table rows", + "description": "Apply alternating background colors to rows in markdown tables" } }, "prompts": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 7b4bfbe6fc..aa66551826 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -7,7 +7,8 @@ "reset": "Restablecer", "select": "Seleccionar", "add": "Añadir encabezado", - "remove": "Eliminar" + "remove": "Eliminar", + "saveFailed": "No se pudieron guardar algunos ajustes. Selecciona Guardar para volver a intentarlo." }, "search": { "placeholder": "Buscar configuración...", @@ -1133,6 +1134,12 @@ "label": "Requerir {{primaryMod}}+Enter para enviar mensajes", "description": "Cuando está activado, debes presionar {{primaryMod}}+Enter para enviar mensajes en lugar de solo Enter" }, + "chatInputEffect": { + "label": "Efecto de la caja de entrada mientras la IA trabaja", + "marquee": "Cinta (borde giratorio)", + "breathing": "Respiración (brillo pulsante)", + "description": "Elige el efecto de animación del borde de la caja de entrada mientras la IA trabaja" + }, "chatFontSize": { "label": "Tamaño de fuente del chat", "description": "Establece el tamaño de fuente (en píxeles) del chat de Zoo Code. Déjalo en el valor predeterminado para que coincida con el tamaño de fuente de VS Code.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Cerrar automaticamente los archivos nuevos creados", "description": "Cuando esta opcion esta activada, las pestanas de archivos que Zoo ha creado (en lugar de modificar) tambien se cierran automaticamente despues de aceptar la modificacion." + }, + "tableStriped": { + "label": "Filas de tabla con rayas", + "description": "Aplica colores de fondo alternos a las filas de las tablas de markdown" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index c8083be95c..a621c78462 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -7,7 +7,8 @@ "reset": "Réinitialiser", "select": "Sélectionner", "add": "Ajouter un en-tête", - "remove": "Supprimer" + "remove": "Supprimer", + "saveFailed": "Certains paramètres n’ont pas pu être enregistrés. Sélectionnez Enregistrer pour réessayer." }, "search": { "placeholder": "Rechercher les paramètres...", @@ -1133,6 +1134,12 @@ "label": "Exiger {{primaryMod}}+Entrée pour envoyer les messages", "description": "Lorsqu'activé, tu dois appuyer sur {{primaryMod}}+Entrée pour envoyer des messages au lieu de simplement Entrée" }, + "chatInputEffect": { + "label": "Effet de la zone de saisie pendant le travail de l'IA", + "marquee": "Défilement (bordure rotative)", + "breathing": "Respiration (lueur pulsante)", + "description": "Choisissez l'effet d'animation de la bordure de la zone de saisie pendant que l'IA travaille" + }, "chatFontSize": { "label": "Taille de police du chat", "description": "Définissez la taille de police (en pixels) du chat Zoo Code. Laissez la valeur par défaut pour correspondre à la taille de police de VS Code.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Fermer automatiquement les nouveaux fichiers créés", "description": "Quand cette option est activée, les onglets des fichiers que Zoo a créés (plutôt que modifiés) sont également fermés automatiquement après l'acceptation de la modification." + }, + "tableStriped": { + "label": "Lignes de tableau alternées", + "description": "Appliquer des couleurs d'arrière-plan alternées aux lignes des tableaux markdown" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 60f7c3a596..51bdfac448 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -7,7 +7,8 @@ "reset": "रीसेट करें", "select": "चुनें", "add": "हेडर जोड़ें", - "remove": "हटाएं" + "remove": "हटाएं", + "saveFailed": "कुछ सेटिंग्स सहेजी नहीं जा सकीं। फिर से प्रयास करने के लिए सहेजें चुनें।" }, "search": { "placeholder": "सेटिंग्स खोजें...", @@ -1133,6 +1134,12 @@ "label": "संदेश भेजने के लिए {{primaryMod}}+Enter की आवश्यकता है", "description": "जब सक्षम हो, तो आपको केवल Enter के बजाय संदेश भेजने के लिए {{primaryMod}}+Enter दबाना होगा" }, + "chatInputEffect": { + "label": "AI कार्य करते समय इनपुट बॉक्स प्रभाव", + "marquee": "मार्की (घूमती हुई सीमा)", + "breathing": "साँस (स्पंदित चमक)", + "description": "AI कार्य करते समय चैट इनपुट बॉक्स पर बॉर्डर एनीमेशन प्रभाव चुनें" + }, "chatFontSize": { "label": "चैट फ़ॉन्ट आकार", "description": "Zoo Code चैट के लिए फ़ॉन्ट आकार (पिक्सेल में) सेट करें। VS Code के फ़ॉन्ट आकार से मेल खाने के लिए डिफ़ॉल्ट पर छोड़ दें।", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Nai banayi gayi files ko svachalit band karo", "description": "Saksham hone par, Zoo dvara banaayi gayi (sanshodhit nahin) files ki tabs ko bhi parivartan svikaar hone ke baad svachalit roop se band kar diya jaata hai." + }, + "tableStriped": { + "label": "धारीदार तालिका पंक्तियाँ", + "description": "मार्कडाउन तालिकाओं की पंक्तियों पर वैकल्पिक पृष्ठभूमि रंग लागू करें" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 8754981a6e..4024024cad 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -7,7 +7,8 @@ "reset": "Reset", "select": "Pilih", "add": "Tambah Header", - "remove": "Hapus" + "remove": "Hapus", + "saveFailed": "Beberapa pengaturan tidak dapat disimpan. Pilih Simpan untuk mencoba lagi." }, "search": { "placeholder": "Cari pengaturan...", @@ -1133,6 +1134,12 @@ "label": "Memerlukan {{primaryMod}}+Enter untuk mengirim pesan", "description": "Ketika diaktifkan, kamu harus menekan {{primaryMod}}+Enter untuk mengirim pesan alih-alih hanya Enter" }, + "chatInputEffect": { + "label": "Efek kotak input saat AI bekerja", + "marquee": "Marquee (batas berputar)", + "breathing": "Bernapas (cahaya berdenyut)", + "description": "Pilih efek animasi batas pada kotak input chat saat AI bekerja" + }, "chatFontSize": { "label": "Ukuran font obrolan", "description": "Atur ukuran font (dalam piksel) untuk obrolan Zoo Code. Biarkan pada nilai default agar sesuai dengan ukuran font VS Code.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Tutup otomatis file baru yang dibuat", "description": "Saat diaktifkan, tab untuk file yang dibuat Zoo (bukan dimodifikasi) juga ditutup secara otomatis setelah perubahan diterima." + }, + "tableStriped": { + "label": "Baris tabel bergaris", + "description": "Terapkan warna latar belakang bergantian pada baris di tabel markdown" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index e14e9532fd..96b1f730f1 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -7,7 +7,8 @@ "reset": "Ripristina", "select": "Seleziona", "add": "Aggiungi intestazione", - "remove": "Rimuovi" + "remove": "Rimuovi", + "saveFailed": "Impossibile salvare alcune impostazioni. Seleziona Salva per riprovare." }, "search": { "placeholder": "Cerca impostazioni...", @@ -1133,6 +1134,12 @@ "label": "Richiedi {{primaryMod}}+Invio per inviare messaggi", "description": "Quando abilitato, devi premere {{primaryMod}}+Invio per inviare messaggi invece di solo Invio" }, + "chatInputEffect": { + "label": "Effetto della casella di input mentre l'IA lavora", + "marquee": "Scorrimento (bordo rotante)", + "breathing": "Respirazione (bagliore pulsante)", + "description": "Scegli l'effetto di animazione del bordo sulla casella di input della chat mentre l'IA lavora" + }, "chatFontSize": { "label": "Dimensione carattere della chat", "description": "Imposta la dimensione del carattere (in pixel) per la chat di Zoo Code. Lascia il valore predefinito per adattarsi alla dimensione del carattere di VS Code.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Chiudi automaticamente i nuovi file creati", "description": "Quando abilitata, le schede dei file che Zoo ha creato (invece di modificare) vengono chiuse automaticamente dopo l'accettazione della modifica." + }, + "tableStriped": { + "label": "Righe della tabella a strisce", + "description": "Applica colori di sfondo alternati alle righe delle tabelle markdown" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 808d575a04..073a54e06a 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -7,7 +7,8 @@ "reset": "リセット", "select": "選択", "add": "ヘッダーを追加", - "remove": "削除" + "remove": "削除", + "saveFailed": "一部の設定を保存できませんでした。「保存」を選択して再試行してください。" }, "search": { "placeholder": "設定を検索...", @@ -1133,6 +1134,12 @@ "label": "メッセージを送信するには{{primaryMod}}+Enterが必要", "description": "有効にすると、Enterだけでなく{{primaryMod}}+Enterを押してメッセージを送信する必要があります" }, + "chatInputEffect": { + "label": "AI作業中の入力ボックスエフェクト", + "marquee": "マーキー(回転する枠線)", + "breathing": "ブリージング(脈動する光)", + "description": "AI作業中にチャット入力ボックスの枠線アニメーション効果を選択" + }, "chatFontSize": { "label": "チャットのフォントサイズ", "description": "Zoo Code チャットのフォントサイズ(ピクセル単位)を設定します。VS Code のフォントサイズに合わせるには既定値のままにしてください。", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "新しく作成されたファイルを自動的に閉じる", "description": "有効にすると、Zooが作成した(変更ではなく)ファイルのタブも、変更を承認した後に自動的に閉じられます。" + }, + "tableStriped": { + "label": "表の行を縞模様にする", + "description": "Markdown テーブルの行に交互の背景色を適用します" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 12faca92c8..317bc186f5 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -7,7 +7,8 @@ "reset": "초기화", "select": "선택", "add": "헤더 추가", - "remove": "삭제" + "remove": "삭제", + "saveFailed": "일부 설정을 저장하지 못했습니다. 저장을 선택하여 다시 시도하세요." }, "search": { "placeholder": "설정 검색...", @@ -1133,6 +1134,12 @@ "label": "메시지를 보내려면 {{primaryMod}}+Enter가 필요", "description": "활성화하면 Enter만으로는 안 되고 {{primaryMod}}+Enter를 눌러야 메시지를 보낼 수 있습니다" }, + "chatInputEffect": { + "label": "AI 작업 중 입력 상자 효과", + "marquee": "마퀴 (회전 테두리)", + "breathing": "호흡 (맥동하는 광채)", + "description": "AI가 작업하는 동안 채팅 입력 상자에 표시할 테두리 애니메이션 효과를 선택하세요" + }, "chatFontSize": { "label": "채팅 글꼴 크기", "description": "Zoo Code 채팅의 글꼴 크기(픽셀)를 설정합니다. VS Code 글꼴 크기에 맞추려면 기본값으로 두세요.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "새로 만든 파일 자동 닫기", "description": "활성화 시, Zoo가 수정한 것이 아니라 새로 만든 파일의 탭도 변경 사항을 승인한 후 자동으로 닫힙니다." + }, + "tableStriped": { + "label": "표 행 줄무늬", + "description": "마크다운 표의 행에 교차 배경색을 적용합니다" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 914e9b85f6..01efc253dd 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -7,7 +7,8 @@ "reset": "Resetten", "select": "Selecteren", "add": "Header toevoegen", - "remove": "Verwijderen" + "remove": "Verwijderen", + "saveFailed": "Sommige instellingen konden niet worden opgeslagen. Selecteer Opslaan om het opnieuw te proberen." }, "search": { "placeholder": "Instellingen zoeken...", @@ -1133,6 +1134,12 @@ "label": "Vereist {{primaryMod}}+Enter om berichten te versturen", "description": "Wanneer ingeschakeld, moet je {{primaryMod}}+Enter indrukken om berichten te versturen in plaats van alleen Enter" }, + "chatInputEffect": { + "label": "Invoervak-effect terwijl AI werkt", + "marquee": "Marquee (draaiende rand)", + "breathing": "Ademen (pulserende gloed)", + "description": "Kies het randanimatie-effect op het chat-invoervak terwijl AI werkt" + }, "chatFontSize": { "label": "Lettergrootte van chat", "description": "Stel de lettergrootte (in pixels) in voor de Zoo Code-chat. Laat op de standaardwaarde staan om overeen te komen met de lettergrootte van VS Code.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Automatisch sluiten van nieuw gemaakte bestanden", "description": "Wanneer ingeschakeld, worden tabbladen van bestanden die Zoo heeft aangemaakt (in plaats van gewijzigd) ook automatisch gesloten nadat de wijziging is geaccepteerd." + }, + "tableStriped": { + "label": "Gestreepte tabelrijen", + "description": "Pas afwisselende achtergrondkleuren toe op rijen in markdown-tabellen" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 26b4551454..9b7030c154 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -7,7 +7,8 @@ "reset": "Resetuj", "select": "Wybierz", "add": "Dodaj nagłówek", - "remove": "Usuń" + "remove": "Usuń", + "saveFailed": "Nie udało się zapisać niektórych ustawień. Wybierz Zapisz, aby spróbować ponownie." }, "search": { "placeholder": "Szukaj ustawień...", @@ -1133,6 +1134,12 @@ "label": "Wymagaj {{primaryMod}}+Enter do wysyłania wiadomości", "description": "Po włączeniu musisz nacisnąć {{primaryMod}}+Enter, aby wysłać wiadomości, zamiast tylko Enter" }, + "chatInputEffect": { + "label": "Efekt pola wprowadzania podczas pracy AI", + "marquee": "Marquee (obracająca się ramka)", + "breathing": "Oddychanie (pulsująca poświata)", + "description": "Wybierz efekt animacji ramki pola wprowadzania czatu podczas pracy AI" + }, "chatFontSize": { "label": "Rozmiar czcionki czatu", "description": "Ustaw rozmiar czcionki (w pikselach) dla czatu Zoo Code. Pozostaw wartość domyślną, aby dopasować ją do rozmiaru czcionki VS Code.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Automatycznie zamykaj nowo utworzone pliki", "description": "Gdy wlaczone, karty plikow, ktore Zoo utworzylo (zamiast zmodyfikowalo), sa rowniez automatycznie zamykane po zaakceptowaniu zmiany." + }, + "tableStriped": { + "label": "Pasiaste wiersze tabeli", + "description": "Zastosuj naprzemienne kolory tła do wierszy w tabelach markdown" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index ed426e52de..d0e7c8fb39 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -7,7 +7,8 @@ "reset": "Redefinir", "select": "Selecionar", "add": "Adicionar cabeçalho", - "remove": "Remover" + "remove": "Remover", + "saveFailed": "Não foi possível salvar algumas configurações. Selecione Salvar para tentar novamente." }, "search": { "placeholder": "Pesquisar configurações...", @@ -1133,6 +1134,12 @@ "label": "Requer {{primaryMod}}+Enter para enviar mensagens", "description": "Quando ativado, você deve pressionar {{primaryMod}}+Enter para enviar mensagens em vez de apenas Enter" }, + "chatInputEffect": { + "label": "Efeito da caixa de entrada enquanto a IA trabalha", + "marquee": "Marquee (borda giratória)", + "breathing": "Respiração (brilho pulsante)", + "description": "Escolha o efeito de animação da borda na caixa de entrada do chat enquanto a IA trabalha" + }, "chatFontSize": { "label": "Tamanho da fonte do chat", "description": "Defina o tamanho da fonte (em pixels) do chat do Zoo Code. Deixe no padrão para corresponder ao tamanho da fonte do VS Code.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Fechar automaticamente novos arquivos criados", "description": "Quando ativada, as abas de arquivos que o Zoo criou (em vez de modificar) tambem sao fechadas automaticamente apos aceitar a modificacao." + }, + "tableStriped": { + "label": "Linhas de tabela listradas", + "description": "Aplica cores de fundo alternadas às linhas em tabelas markdown" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index b810dea6b9..ba2d98511b 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -7,7 +7,8 @@ "reset": "Сбросить", "select": "Выбрать", "add": "Добавить заголовок", - "remove": "Удалить" + "remove": "Удалить", + "saveFailed": "Не удалось сохранить некоторые настройки. Нажмите «Сохранить», чтобы повторить попытку." }, "search": { "placeholder": "Поиск параметров...", @@ -1133,6 +1134,12 @@ "label": "Требовать {{primaryMod}}+Enter для отправки сообщений", "description": "Если включено, необходимо нажать {{primaryMod}}+Enter для отправки сообщений вместо простого Enter" }, + "chatInputEffect": { + "label": "Эффект поля ввода во время работы ИИ", + "marquee": "Бегущая строка (вращающаяся рамка)", + "breathing": "Дыхание (пульсирующее свечение)", + "description": "Выберите эффект анимации рамки поля ввода чата во время работы ИИ" + }, "chatFontSize": { "label": "Размер шрифта чата", "description": "Задайте размер шрифта (в пикселях) для чата Zoo Code. Оставьте значение по умолчанию, чтобы соответствовать размеру шрифта VS Code.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Автоматически закрывать новые созданные файлы", "description": "Если включено, вкладки файлов, которые Zoo создал (а не изменил), также автоматически закрываются после принятия изменения." + }, + "tableStriped": { + "label": "Полосатые строки таблицы", + "description": "Применять чередующиеся цвета фона к строкам таблиц markdown" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 74a38fe1c0..869ffd0503 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -7,7 +7,8 @@ "reset": "Sıfırla", "select": "Seç", "add": "Başlık Ekle", - "remove": "Kaldır" + "remove": "Kaldır", + "saveFailed": "Bazı ayarlar kaydedilemedi. Tekrar denemek için Kaydet’i seçin." }, "search": { "placeholder": "Ayarları ara...", @@ -1133,6 +1134,12 @@ "label": "Mesaj göndermek için {{primaryMod}}+Enter gerekli", "description": "Etkinleştirildiğinde, sadece Enter yerine mesaj göndermek için {{primaryMod}}+Enter'a basmalısınız" }, + "chatInputEffect": { + "label": "AI çalışırken giriş kutusu efekti", + "marquee": "Kayan yazı (dönen kenarlık)", + "breathing": "Nefes alma (nabız gibi parlayan)", + "description": "AI çalışırken sohbet giriş kutusundaki kenarlık animasyon efektini seçin" + }, "chatFontSize": { "label": "Sohbet yazı tipi boyutu", "description": "Zoo Code sohbeti için yazı tipi boyutunu (piksel cinsinden) ayarlayın. VS Code yazı tipi boyutuyla eşleşmesi için varsayılan değerde bırakın.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Yeni olusturulan dosyalari otomatik kapat", "description": "Etkinlestirildiginde, Zoo'nun olusturdugu (degistirmedigi) dosyalarin sekmeleri de degisiklik kabul edildikten sonra otomatik olarak kapatilir." + }, + "tableStriped": { + "label": "Çizgili tablo satırları", + "description": "Markdown tablolarındaki satırlara alternatif arka plan renkleri uygula" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 8b79b0fd4f..addf610454 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -7,7 +7,8 @@ "reset": "Đặt lại", "select": "Chọn", "add": "Thêm tiêu đề", - "remove": "Xóa" + "remove": "Xóa", + "saveFailed": "Không thể lưu một số cài đặt. Chọn Lưu để thử lại." }, "search": { "placeholder": "Tìm kiếm cài đặt...", @@ -1133,6 +1134,12 @@ "label": "Yêu cầu {{primaryMod}}+Enter để gửi tin nhắn", "description": "Khi được bật, bạn phải nhấn {{primaryMod}}+Enter để gửi tin nhắn thay vì chỉ nhấn Enter" }, + "chatInputEffect": { + "label": "Hiệu ứng ô nhập khi AI đang làm việc", + "marquee": "Marquee (viền xoay)", + "breathing": "Thở (ánh sáng nhấp nháy)", + "description": "Chọn hiệu ứng hoạt hình viền trên ô nhập chat khi AI đang làm việc" + }, "chatFontSize": { "label": "Cỡ chữ trò chuyện", "description": "Đặt cỡ chữ (tính bằng pixel) cho cuộc trò chuyện Zoo Code. Để ở giá trị mặc định để khớp với cỡ chữ của VS Code.", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "Tự động đóng các tệp mới tạo", "description": "Khi bật, các tab của các tệp mà Zoo đã tạo (thay vì chỉnh sửa) cũng sẽ tự động đóng sau khi chấp nhận thay đổi." + }, + "tableStriped": { + "label": "Hàng bảng sọc", + "description": "Áp dụng màu nền xen kẽ cho các hàng trong bảng markdown" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 581687216f..1ba20255c9 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -7,7 +7,8 @@ "reset": "恢复默认设置", "select": "选择", "add": "添加标头", - "remove": "移除" + "remove": "移除", + "saveFailed": "部分设置未能保存。请选择“保存”重试。" }, "search": { "placeholder": "搜索设置...", @@ -1133,6 +1134,12 @@ "label": "需要 {{primaryMod}}+Enter 发送消息", "description": "启用后,必须按 {{primaryMod}}+Enter 发送消息,而不仅仅是 Enter" }, + "chatInputEffect": { + "label": "AI工作时输入框动效", + "marquee": "走马灯效果(旋转光晕)", + "breathing": "呼吸效果(脉冲光晕)", + "description": "选择 AI 工作时输入框的边框动画效果" + }, "chatFontSize": { "label": "聊天字体大小", "description": "设置 Zoo Code 聊天的字体大小(以像素为单位)。保留默认值以匹配 VS Code 的字体大小。", @@ -1149,6 +1156,10 @@ "autoCloseZooOpenedNewFiles": { "label": "自动关闭新建的文件", "description": "启用后,Zoo 新建(而非修改)的文件标签页在接受更改后也会自动关闭。" + }, + "tableStriped": { + "label": "表格隔行着色", + "description": "为 Markdown 表格的行应用交替背景色" } }, "skills": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 54d4dae1b9..781f8a97fb 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -7,7 +7,8 @@ "reset": "重設", "select": "選擇", "add": "新增標頭", - "remove": "移除" + "remove": "移除", + "saveFailed": "部分設定無法儲存。請選擇「儲存」重試。" }, "header": { "title": "設定", @@ -173,6 +174,12 @@ "label": "需要 {{primaryMod}}+Enter 傳送訊息", "description": "啟用後,必須按 {{primaryMod}}+Enter 傳送訊息,而不只是 Enter" }, + "chatInputEffect": { + "label": "AI 工作時的輸入框動效", + "marquee": "跑馬燈效果(旋轉光暈)", + "breathing": "呼吸效果(脈動光暈)", + "description": "選擇 AI 工作時聊天輸入框的邊框動畫效果" + }, "chatFontSize": { "label": "聊天字型大小", "description": "設定 Zoo Code 聊天的字型大小(以像素為單位)。保留預設值以符合 VS Code 的字型大小。", @@ -189,6 +196,10 @@ "autoCloseZooOpenedNewFiles": { "label": "自動關閉新建立的檔案", "description": "啟用後,Zoo 新建(而非修改)的檔案分頁在接受變更後也會自動關閉。" + }, + "tableStriped": { + "label": "表格隔行著色", + "description": "為 Markdown 表格的列套用交替背景色" } }, "prompts": { diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index bccbdf7031..9705b2952a 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -472,7 +472,7 @@ input[cmdk-input]:focus { } /* - * Use geometric precision for codicons to avoid blurriness + * Use geometric precision for codicons to avoid blurriness */ .codicon[class*="codicon-"] { @@ -603,3 +603,106 @@ input[cmdk-input]:focus { padding: 8px; margin: -8px; } + +/* ChatTextArea marquee border — conic-gradient light beam orbits the ring. + mask clips center (content-box) away → text never blocked. + 1px ring, dark blue beam. Shown only while AI is executing. */ +@property --angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} + +@keyframes border-spin { + from { + --angle: 0deg; + } + to { + --angle: 360deg; + } +} + +/* ChatTextArea breathing border — pulsing glow + 1px ring. + Shown only while AI is executing (isStreaming gate in ChatTextArea) + when chatInputEffect is set to "breathing". */ +@keyframes streaming-glow { + 0%, + 100% { + box-shadow: 0 0 2px color-mix(in srgb, var(--vscode-focusBorder, #03638f) 8%, transparent); + } + 50% { + box-shadow: + 0 0 12px color-mix(in srgb, var(--vscode-focusBorder, #04567b) 45%, transparent), + 0 0 24px color-mix(in srgb, var(--vscode-focusBorder, #012434) 25%, transparent); + } +} + +.animate-streaming-glow { + animation: streaming-glow 2s ease-in-out infinite; +} + +/* Breathing border ring — 1px blue ring with pulsing opacity */ +@keyframes border-breathe { + 0%, + 100% { + opacity: 0.15; + } + 50% { + opacity: 1; + } +} + +/* "Thinking..." shimmer — VS Code chat progress style, continuous narrow band. + Ported from workbench chat's chat-thinking-shimmer: a light band sweeps + continuously across the text on a 2s loop. The workbench-only variable + --vscode-chat-thinkingShimmer is not available inside the webview, so the + highlight band is synthesized from the foreground color instead. */ +@keyframes thinking-shine { + 0% { + background-position: 100% 0; + } + 100% { + background-position: -100% 0; + } +} + +.animate-thinking-shine { + background-image: linear-gradient( + 90deg, + color-mix(in srgb, var(--vscode-descriptionForeground) 45%, transparent) 0%, + color-mix(in srgb, var(--vscode-descriptionForeground) 45%, transparent) 30%, + color-mix(in srgb, var(--vscode-foreground) 100%, #ffffff 40%) 50%, + color-mix(in srgb, var(--vscode-descriptionForeground) 45%, transparent) 70%, + color-mix(in srgb, var(--vscode-descriptionForeground) 45%, transparent) 100% + ); + background-size: 400% auto; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + animation: thinking-shine 2s linear infinite; + will-change: background-position; +} + +/* Respect the user's reduced-motion preference for the chat input effects. + Disables the streaming glow, breathing ring, spinning border, and the + "Thinking..." shimmer. For the shimmer we also drop the animated gradient + background and restore the text fill/colour to the VS Code foreground so the + label stays legible without motion. */ +@media (prefers-reduced-motion: reduce) { + .animate-streaming-glow, + .animate-thinking-shine, + .animate-\[border-spin_3s_linear_infinite\], + .animate-\[border-breathe_2s_ease-in-out_infinite\] { + animation: none !important; + } + + .animate-thinking-shine { + background-image: none; + background-clip: border-box; + -webkit-background-clip: border-box; + -webkit-text-fill-color: var(--vscode-foreground); + color: var(--vscode-foreground); + will-change: auto; + } +}