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/__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/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 60a92eebaa..dccb3df3fa 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -230,6 +230,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 { @@ -814,6 +819,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 +846,7 @@ export class ClineProvider } this._disposed = true + this.settingsSaveController.abort() this._postStateToWebviewThrottled.cancel() this.log("Disposing ClineProvider...") @@ -1016,6 +1023,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 +1710,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) @@ -1876,9 +1893,12 @@ export class ClineProvider name: string, providerSettings: ProviderSettings, activate: boolean = true, + saveSignal?: AbortSignal, ): Promise { try { - return await this.enqueueProviderProfileMutation(async (signal) => { + 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 @@ -1890,6 +1910,8 @@ export class ClineProvider if (activate) { const { mode } = await this.getState() + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + signal.throwIfAborted() // These promises do the following: // 1. Adds or updates the list of provider profiles. @@ -1902,12 +1924,13 @@ export class ClineProvider // 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("listApiConfigMeta", listApiConfigMeta), this.updateGlobalState("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), ]) + signal.throwIfAborted() // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) @@ -1915,13 +1938,17 @@ export class ClineProvider // 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()) + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + signal.throwIfAborted() + await this.updateGlobalState("listApiConfigMeta", listApiConfigMeta) } + signal.throwIfAborted() await this.postStateToWebview() return id }) } catch (error) { + saveSignal?.throwIfAborted() this.log( `Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) @@ -2654,6 +2681,8 @@ export class ClineProvider reasoningBlockCollapsed, chatFontSize, enterBehavior, + chatInputEffect, + tableStriped, cloudUserInfo, cloudIsAuthenticated, sharingEnabled, @@ -2833,6 +2862,8 @@ export class ClineProvider reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, chatFontSize, enterBehavior: enterBehavior ?? "send", + chatInputEffect: chatInputEffect ?? "marquee", + tableStriped: tableStriped ?? false, cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, cloudAuthSkipModel: this.context.globalState.get("roo-auth-skip-model") ?? false, @@ -3064,6 +3095,8 @@ export class ClineProvider reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, chatFontSize: stateValues.chatFontSize, enterBehavior: stateValues.enterBehavior ?? "send", + chatInputEffect: stateValues.chatInputEffect ?? "marquee", + tableStriped: stateValues.tableStriped ?? false, cloudUserInfo, cloudIsAuthenticated, sharingEnabled, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 7c85a6b372..562e939477 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -34,6 +34,7 @@ import { safeWriteJson } from "../../../utils/safeWriteJson" import { ClineProvider } from "../ClineProvider" import { webviewMessageHandler } 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 +537,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() } }), @@ -671,6 +669,87 @@ 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("stops an active profile save after disposal during a profile metadata read", async () => { + let release!: () => void + const list = vi.spyOn(provider.providerSettingsManager, "listConfig").mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve([]) + }), + ) + const write = vi.spyOn(provider.contextProxy, "setValue") + const saving = webviewMessageHandler(provider, { + type: "upsertApiConfiguration", + text: "saved-profile", + apiConfiguration: {}, + }) + await vi.waitFor(() => expect(list).toHaveBeenCalled()) + const cancelled = expect(saving).rejects.toMatchObject({ name: "AbortError" }) + sidebar.disposed.fire() + await cancelled + release() + await enqueueSettingsSave(provider.contextProxy, new AbortController().signal, async () => {}) + expect(write).not.toHaveBeenCalled() + list.mockRestore() + write.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) @@ -1977,6 +2056,28 @@ 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 tableStriped to false", async () => { + await provider.resolveWebviewView(mockWebviewView) + + expect(await provider[method]()).toMatchObject({ chatInputEffect: "marquee", tableStriped: false }) + }) + }) + 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..2ba809ee0b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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 @@ -731,12 +785,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 +803,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 +2389,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 +2850,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 +2867,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 +2882,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..5edf0433e1 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..276647dbb8 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/Markdown.spec.tsx @@ -0,0 +1,28 @@ +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 }, + { tableStriped: undefined, expectedStriped: false }, + ])( + "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..6f90a137dc --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ReasoningBlock.spec.tsx @@ -0,0 +1,90 @@ +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 }, + { tableStriped: undefined, expectedStriped: false }, + ])( + "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..3eb8c44f60 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -32,6 +32,8 @@ const ALERT_LABELS: Record = { interface MarkdownBlockProps { markdown?: string + /** Opt-in zebra striping for markdown tables (Settings → UI → tableStriped). */ + striped?: boolean } const StyledMarkdown = styled.div` @@ -213,7 +215,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 +277,7 @@ const StyledMarkdown = styled.div` } ` -const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { +const MarkdownBlock = memo(({ markdown, striped = false }: MarkdownBlockProps) => { const components = useMemo( () => ({ table: ({ children, ...props }: any) => { @@ -395,7 +399,7 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { ) return ( - + { expect(screen.getByText("Third level ordered")).toBeInTheDocument() expect(screen.getByText("Back to first level")).toBeInTheDocument() }) + + it("should not apply table striping by default", () => { + const { container } = render() + + const root = container.firstElementChild as HTMLElement + expect(root).not.toHaveClass("table-striped") + }) + + it("should apply table striping when the striped prop is enabled", () => { + const { container } = render() + + const root = container.firstElementChild as HTMLElement + expect(root).toHaveClass("table-striped") + }) }) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 61ba954dd5..2c3a4e7925 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: tableStriped ?? false, 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,352 @@ 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 unstriped tables 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")) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ chatInputEffect: "marquee", tableStriped: false }), + }), + ) + }) +}) + +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..5801635aef 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,55 @@ 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("renders the table striping checkbox unchecked by default", () => { + const { getByTestId } = render() + const checkbox = getByTestId("table-striped-checkbox") as HTMLInputElement + expect(checkbox).toBeTruthy() + expect(checkbox.checked).toBe(false) + }) + + 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..76f0437bb4 100644 --- a/webview-ui/src/components/settings/__tests__/UISettings.visual.fixture.tsx +++ b/webview-ui/src/components/settings/__tests__/UISettings.visual.fixture.tsx @@ -8,6 +8,7 @@ import { AppProviders } from "../../../../playwright/AppProviders" interface UIState { reasoningBlockCollapsed: boolean enterBehavior: "send" | "newline" + chatInputEffect: "marquee" | "breathing" chatFontSize?: number autoCloseZooOpenedFiles?: boolean autoCloseZooOpenedFilesAfterUserEdited?: boolean @@ -18,6 +19,7 @@ export function UISettingsStory() { const [state, setState] = useState({ reasoningBlockCollapsed: true, enterBehavior: "send", + chatInputEffect: "marquee", chatFontSize: 14, autoCloseZooOpenedFiles: true, autoCloseZooOpenedFilesAfterUserEdited: true, @@ -33,6 +35,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; + } +}