diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index b20539afe49..67b0099fc55 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -365,7 +365,7 @@ export type ExtensionState = Pick< marketplaceInstalledMetadata?: { project: Record; global: Record } profileThresholds: Record hasOpenedModeSelector: boolean - openRouterImageApiKey?: string + openRouterImageApiKeyConfigured: boolean messageQueue?: QueuedMessage[] lastShownAnnouncementId?: string apiModelId?: string diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7bd969e52d0..aa1032cf0d6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -951,6 +951,16 @@ export class ClineProvider }) this.webviewDisposables.push(configDisposable) + // Re-broadcast state when a secret changes in another window or is updated by the OS keyring, + // so openRouterImageApiKeyConfigured stays accurate across all open windows. + const secretsDisposable = this.context.secrets.onDidChange(async ({ key }) => { + if (key === "openRouterImageApiKey") { + await this.contextProxy.refreshSecrets() + await this.postStateToWebview() + } + }) + this.webviewDisposables.push(secretsDisposable) + // If the extension is starting a new session, clear previous task state. // But don't clear if there's already an active task (e.g., resumed via IPC/bridge). const currentTask = this.getCurrentTask() @@ -2352,7 +2362,7 @@ export class ClineProvider maxGitStatusFiles: maxGitStatusFiles ?? 0, taskSyncEnabled, imageGenerationProvider, - openRouterImageApiKey, + openRouterImageApiKeyConfigured: !!openRouterImageApiKey, openRouterImageGenerationSelectedModel, openAiCodexIsAuthenticated: await (async () => { try { @@ -2376,7 +2386,7 @@ export class ClineProvider Omit< ExtensionState, "clineMessages" | "renderContext" | "hasOpenedModeSelector" | "version" | "shouldShowAnnouncement" - > + > & { openRouterImageApiKey?: string } > { const stateValues = this.contextProxy.getValues() const customModes = await this.customModesManager.getCustomModes() @@ -2572,6 +2582,7 @@ export class ClineProvider taskSyncEnabled, imageGenerationProvider: stateValues.imageGenerationProvider, openRouterImageApiKey: stateValues.openRouterImageApiKey, + openRouterImageApiKeyConfigured: !!stateValues.openRouterImageApiKey, openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, } } diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 87c6ea968cc..9a02821d99a 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -167,6 +167,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { get: vi.fn().mockImplementation((key: string) => secrets[key]), store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, workspaceState: { get: vi.fn().mockReturnValue(undefined), diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index 4bb01347a3d..7c0a0011ab3 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -112,6 +112,7 @@ describe("ClineProvider flicker-free cancel", () => { get: vi.fn().mockResolvedValue(undefined), store: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, workspaceState: { get: vi.fn().mockReturnValue(undefined), diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts index 2cf9d4cae8b..98accb2f765 100644 --- a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -258,6 +258,7 @@ describe("ClineProvider - Lock API Config Across Modes", () => { delete secrets[key] return Promise.resolve() }), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, workspaceState: { get: vi.fn().mockImplementation((key: string, defaultValue?: unknown) => { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index cfa4b0317f8..3616bb77918 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -372,6 +372,7 @@ describe("ClineProvider", () => { get: vi.fn().mockImplementation((key: string) => secrets[key]), store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, workspaceState: { get: vi.fn().mockReturnValue(undefined), @@ -549,7 +550,7 @@ describe("ClineProvider", () => { profileThresholds: {}, hasOpenedModeSelector: false, diagnosticsEnabled: true, - openRouterImageApiKey: undefined, + openRouterImageApiKeyConfigured: false, openRouterImageGenerationSelectedModel: undefined, taskSyncEnabled: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, @@ -564,6 +565,75 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalledWith(message) }) + describe("getStateToPostToWebview secrets redaction", () => { + test("omits raw openRouterImageApiKey from broadcast payload when key is set", async () => { + // Use contextProxy.setValue rather than mocking secrets.get because ContextProxy only + // reads secrets.get during initialize() (which ran during provider construction). Any + // mock changes after construction are too late — they never reach secretCache. + await provider.contextProxy.setValue("openRouterImageApiKey", "sk-or-v1-supersecret") + + const state = await provider.getStateToPostToWebview() + + expect("openRouterImageApiKey" in state).toBe(false) + expect(state.openRouterImageApiKeyConfigured).toBe(true) + }) + + test("sets openRouterImageApiKeyConfigured to false when no key is stored", async () => { + const state = await provider.getStateToPostToWebview() + + expect("openRouterImageApiKey" in state).toBe(false) + expect(state.openRouterImageApiKeyConfigured).toBe(false) + }) + }) + + describe("secrets.onDidChange integration", () => { + test("rebroadcasts state when openRouterImageApiKey changes in another window", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Capture the onDidChange callback registered by resolveWebviewView + const onDidChangeMock = mockContext.secrets.onDidChange as ReturnType + expect(onDidChangeMock).toHaveBeenCalled() + const secretsChangeHandler = onDidChangeMock.mock.calls[0][0] as (event: { key: string }) => Promise + + // Simulate the OS keyring updating the key in another VS Code window + await provider.contextProxy.setValue("openRouterImageApiKey", "sk-or-v1-newkey") + mockPostMessage.mockClear() + + await secretsChangeHandler({ key: "openRouterImageApiKey" }) + + // postStateToWebview must have fired so the webview gets the updated configured flag + expect(mockPostMessage).toHaveBeenCalled() + const stateMessage = mockPostMessage.mock.calls.find(([msg]: [any]) => msg?.type === "state") + expect(stateMessage).toBeDefined() + expect(stateMessage![0].state.openRouterImageApiKeyConfigured).toBe(true) + expect("openRouterImageApiKey" in stateMessage![0].state).toBe(false) + }) + + test("does not rebroadcast state for unrelated secret key changes", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const onDidChangeMock = mockContext.secrets.onDidChange as ReturnType + const secretsChangeHandler = onDidChangeMock.mock.calls[0][0] as (event: { key: string }) => Promise + + mockPostMessage.mockClear() + + await secretsChangeHandler({ key: "someOtherSecret" }) + + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + test("disposes the secrets listener when the webview is disposed", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const onDidChangeMock = mockContext.secrets.onDidChange as ReturnType + const disposable = onDidChangeMock.mock.results[0].value as { dispose: ReturnType } + + await provider.dispose() + + expect(disposable.dispose).toHaveBeenCalled() + }) + }) + test("postMessageToWebview does not throw when webview is disposed", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -2014,6 +2084,7 @@ describe("Project MCP Settings", () => { get: vi.fn(), store: vi.fn(), delete: vi.fn(), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, workspaceState: { get: vi.fn().mockReturnValue(undefined), @@ -2155,7 +2226,12 @@ describe.skip("ContextProxy integration", () => { update: vi.fn().mockResolvedValue(undefined), keys: vi.fn().mockReturnValue([]), }, - secrets: { get: vi.fn(), store: vi.fn(), delete: vi.fn() }, + secrets: { + get: vi.fn(), + store: vi.fn(), + delete: vi.fn(), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, extensionUri: {} as vscode.Uri, globalStorageUri: { fsPath: "/test/path" }, extension: { packageJSON: { version: "1.0.0" } }, @@ -2225,7 +2301,12 @@ describe("getTelemetryProperties", () => { update: vi.fn().mockResolvedValue(undefined), keys: vi.fn().mockReturnValue([]), }, - secrets: { get: vi.fn(), store: vi.fn(), delete: vi.fn() }, + secrets: { + get: vi.fn(), + store: vi.fn(), + delete: vi.fn(), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, extensionUri: {} as vscode.Uri, globalStorageUri: { fsPath: "/test/path" }, extension: { packageJSON: { version: "1.0.0" } }, @@ -2386,6 +2467,7 @@ describe("ClineProvider - Router Models", () => { get: vi.fn().mockImplementation((key: string) => secrets[key]), store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, workspaceState: { get: vi.fn().mockReturnValue(undefined), @@ -2703,6 +2785,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { get: vi.fn().mockImplementation((key: string) => secrets[key]), store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, workspaceState: { get: vi.fn().mockReturnValue(undefined), diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index abef31af89f..8f95bb7a2cc 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -236,6 +236,7 @@ describe("ClineProvider - Sticky Mode", () => { delete secrets[key] return Promise.resolve() }), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, workspaceState: { get: vi.fn().mockReturnValue(undefined), diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index 605f5c1a6ff..c13504195f5 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -241,6 +241,7 @@ describe("ClineProvider - Sticky Provider Profile", () => { delete secrets[key] return Promise.resolve() }), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, workspaceState: { get: vi.fn().mockReturnValue(undefined), diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index d1bbd9bca68..f4d4e110fee 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -279,6 +279,7 @@ describe("ClineProvider Task History Synchronization", () => { get: vi.fn().mockImplementation((key: string) => secrets[key]), store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, workspaceState: { get: vi.fn().mockReturnValue(undefined), diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx index 23786ce0b98..5e867977edd 100644 --- a/webview-ui/src/components/settings/ExperimentalSettings.tsx +++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx @@ -21,7 +21,7 @@ type ExperimentalSettingsProps = HTMLAttributes & { apiConfiguration?: any setApiConfigurationField?: any imageGenerationProvider?: ImageGenerationProvider - openRouterImageApiKey?: string + openRouterImageApiKeyConfigured: boolean openRouterImageGenerationSelectedModel?: string setImageGenerationProvider?: (provider: ImageGenerationProvider) => void setOpenRouterImageApiKey?: (apiKey: string) => void @@ -34,7 +34,7 @@ export const ExperimentalSettings = ({ apiConfiguration, setApiConfigurationField, imageGenerationProvider, - openRouterImageApiKey, + openRouterImageApiKeyConfigured, openRouterImageGenerationSelectedModel, setImageGenerationProvider, setOpenRouterImageApiKey, @@ -74,7 +74,7 @@ export const ExperimentalSettings = ({ setExperimentEnabled(EXPERIMENT_IDS.IMAGE_GENERATION, enabled) } imageGenerationProvider={imageGenerationProvider} - openRouterImageApiKey={openRouterImageApiKey} + openRouterImageApiKeyConfigured={openRouterImageApiKeyConfigured} openRouterImageGenerationSelectedModel={openRouterImageGenerationSelectedModel} setImageGenerationProvider={setImageGenerationProvider} setOpenRouterImageApiKey={setOpenRouterImageApiKey} diff --git a/webview-ui/src/components/settings/ImageGenerationSettings.tsx b/webview-ui/src/components/settings/ImageGenerationSettings.tsx index ccbd0a3fff9..6c2a00dac30 100644 --- a/webview-ui/src/components/settings/ImageGenerationSettings.tsx +++ b/webview-ui/src/components/settings/ImageGenerationSettings.tsx @@ -7,7 +7,7 @@ interface ImageGenerationSettingsProps { enabled: boolean onChange: (enabled: boolean) => void imageGenerationProvider?: ImageGenerationProvider - openRouterImageApiKey?: string + openRouterImageApiKeyConfigured: boolean openRouterImageGenerationSelectedModel?: string setImageGenerationProvider: (provider: ImageGenerationProvider) => void setOpenRouterImageApiKey: (apiKey: string) => void @@ -18,7 +18,7 @@ export const ImageGenerationSettings = ({ enabled, onChange, imageGenerationProvider, - openRouterImageApiKey, + openRouterImageApiKeyConfigured, openRouterImageGenerationSelectedModel, setImageGenerationProvider, setOpenRouterImageApiKey, @@ -88,7 +88,7 @@ export const ImageGenerationSettings = ({ } const requiresApiKey = currentProvider === "openrouter" - const isConfigured = !requiresApiKey || (requiresApiKey && openRouterImageApiKey) + const isConfigured = !requiresApiKey || openRouterImageApiKeyConfigured return (
@@ -133,9 +133,14 @@ export const ImageGenerationSettings = ({ {t("settings:experimental.IMAGE_GENERATION.openRouterApiKeyLabel")} handleApiKeyChange(e.target.value)} - placeholder={t("settings:experimental.IMAGE_GENERATION.openRouterApiKeyPlaceholder")} + placeholder={ + openRouterImageApiKeyConfigured + ? t( + "settings:experimental.IMAGE_GENERATION.openRouterApiKeyConfiguredPlaceholder", + ) + : t("settings:experimental.IMAGE_GENERATION.openRouterApiKeyPlaceholder") + } className="w-full" type="password" /> diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 47e087615e3..451ceab814c 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -130,6 +130,7 @@ const SettingsView = forwardRef(({ onDone, t const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) const [isChangeDetected, setChangeDetected] = useState(false) const [errorMessage, setErrorMessage] = useState(undefined) + const [pendingImageApiKey, setPendingImageApiKey] = useState(null) const [activeTab, setActiveTab] = useState( targetSection && sectionNames.includes(targetSection as SectionName) ? (targetSection as SectionName) @@ -196,7 +197,7 @@ const SettingsView = forwardRef(({ onDone, t maxDiagnosticMessages, includeTaskHistoryInEnhance, imageGenerationProvider, - openRouterImageApiKey, + openRouterImageApiKeyConfigured, openRouterImageGenerationSelectedModel, reasoningBlockCollapsed, enterBehavior, @@ -323,13 +324,8 @@ const SettingsView = forwardRef(({ onDone, t }, []) const setOpenRouterImageApiKey = useCallback((apiKey: string) => { - setCachedState((prevState) => { - if (prevState.openRouterImageApiKey !== apiKey) { - setChangeDetected(true) - } - - return { ...prevState, openRouterImageApiKey: apiKey } - }) + setPendingImageApiKey(apiKey) + setChangeDetected(true) }, []) const setImageGenerationSelectedModel = useCallback((model: string) => { @@ -418,7 +414,7 @@ const SettingsView = forwardRef(({ onDone, t maxGitStatusFiles: maxGitStatusFiles ?? 0, profileThresholds, imageGenerationProvider, - openRouterImageApiKey, + ...(pendingImageApiKey !== null ? { openRouterImageApiKey: pendingImageApiKey || undefined } : {}), openRouterImageGenerationSelectedModel, experiments, customSupportPrompts, @@ -431,6 +427,7 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting }) vscode.postMessage({ type: "debugSetting", bool: cachedState.debug }) + setPendingImageApiKey(null) setChangeDetected(false) } } @@ -454,6 +451,7 @@ const SettingsView = forwardRef(({ onDone, t if (confirm) { // Discard changes: Reset state and flag setCachedState(extensionState) // Revert to original state + setPendingImageApiKey(null) setChangeDetected(false) // Reset change flag confirmDialogHandler.current?.() // Execute the pending action (e.g., tab switch) } @@ -904,7 +902,7 @@ const SettingsView = forwardRef(({ onDone, t apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} imageGenerationProvider={imageGenerationProvider} - openRouterImageApiKey={openRouterImageApiKey as string | undefined} + openRouterImageApiKeyConfigured={openRouterImageApiKeyConfigured ?? false} openRouterImageGenerationSelectedModel={ openRouterImageGenerationSelectedModel as string | undefined } diff --git a/webview-ui/src/components/settings/__tests__/ImageGenerationSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ImageGenerationSettings.spec.tsx index 12ad1af591e..034cb662ee3 100644 --- a/webview-ui/src/components/settings/__tests__/ImageGenerationSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ImageGenerationSettings.spec.tsx @@ -19,7 +19,7 @@ describe("ImageGenerationSettings", () => { enabled: false, onChange: mockOnChange, imageGenerationProvider: undefined, - openRouterImageApiKey: undefined, + openRouterImageApiKeyConfigured: false, openRouterImageGenerationSelectedModel: undefined, setImageGenerationProvider: mockSetImageGenerationProvider, setOpenRouterImageApiKey: mockSetOpenRouterImageApiKey, @@ -44,7 +44,7 @@ describe("ImageGenerationSettings", () => { render( , ) @@ -90,6 +90,21 @@ describe("ImageGenerationSettings", () => { ).toBeInTheDocument() }) + it("should show configured placeholder when openRouterImageApiKeyConfigured is true", () => { + const { getByPlaceholderText } = render( + , + ) + + expect( + getByPlaceholderText("settings:experimental.IMAGE_GENERATION.openRouterApiKeyConfiguredPlaceholder"), + ).toBeInTheDocument() + }) + it("should not render API key field when provider is roo", () => { const { queryByPlaceholderText } = render( , diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index 42239e33a37..24918b448d3 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -299,7 +299,7 @@ describe("SettingsView - Change Detection Fix", () => { includeDiagnosticMessages: false, maxDiagnosticMessages: 50, includeTaskHistoryInEnhance: true, - openRouterImageApiKey: undefined, + openRouterImageApiKeyConfigured: false, openRouterImageGenerationSelectedModel: undefined, reasoningBlockCollapsed: true, ...overrides, 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 83be2509d08..926a04bda44 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 @@ -5,7 +5,12 @@ import React from "react" import SettingsView from "../SettingsView" -// Mock vscode API +// Mock the vscode utility module (acquireVsCodeApi is only available inside the VS Code webview) +vi.mock("@src/utils/vscode", () => ({ + vscode: { postMessage: vi.fn(), getState: vi.fn(), setState: vi.fn() }, +})) + +// Legacy global mock retained for other test setups that may rely on it const mockPostMessage = vi.fn() const mockVscode = { postMessage: mockPostMessage, @@ -242,6 +247,8 @@ vi.mock("../SettingsSearch", () => ({ import { useExtensionState } from "@src/context/ExtensionStateContext" import ApiOptions from "../ApiOptions" +import { ExperimentalSettings } from "../ExperimentalSettings" +import { vscode } from "@src/utils/vscode" describe("SettingsView - Unsaved Changes Detection", () => { let queryClient: QueryClient @@ -304,7 +311,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { includeDiagnosticMessages: false, maxDiagnosticMessages: 50, includeTaskHistoryInEnhance: true, - openRouterImageApiKey: undefined, + openRouterImageApiKeyConfigured: false, openRouterImageGenerationSelectedModel: undefined, reasoningBlockCollapsed: true, } @@ -316,6 +323,8 @@ describe("SettingsView - Unsaved Changes Detection", () => { // Don't do anything with props, just render a div return
ApiOptions
}) + // Reset ExperimentalSettings to silent default + vi.mocked(ExperimentalSettings).mockImplementation(() =>
ExperimentalSettings
) queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -603,4 +612,96 @@ describe("SettingsView - Unsaved Changes Detection", () => { // No dialog should appear expect(screen.queryByText("settings:unsavedChangesDialog.title")).not.toBeInTheDocument() }) + + describe("pending image API key", () => { + const renderWithApiKeyTrigger = () => { + vi.mocked(ExperimentalSettings).mockImplementation(({ setOpenRouterImageApiKey }: any) => ( +
+ + +
+ )) + // Render with the experimental tab active so ExperimentalSettings is mounted + return render( + + + , + ) + } + + it("typing a new key marks settings as changed and includes key in save payload", async () => { + renderWithApiKeyTrigger() + + await waitFor(() => expect(screen.getByTestId("save-button")).toBeInTheDocument()) + + fireEvent.click(screen.getByTestId("set-api-key")) + + // Save button should now be enabled + await waitFor(() => { + expect((screen.getByTestId("save-button") as HTMLButtonElement).disabled).toBe(false) + }) + + fireEvent.click(screen.getByTestId("save-button")) + + await waitFor(() => { + const calls = vi.mocked(vscode.postMessage).mock.calls + const updateCall = (calls.find(([msg]: any) => msg?.type === "updateSettings") as any)?.[0] + expect(updateCall).toBeDefined() + expect(updateCall.updatedSettings.openRouterImageApiKey).toBe("sk-or-new-key") + }) + }) + + it("clearing the key sends undefined (triggers deletion) rather than empty string", async () => { + renderWithApiKeyTrigger() + + await waitFor(() => expect(screen.getByTestId("save-button")).toBeInTheDocument()) + + fireEvent.click(screen.getByTestId("clear-api-key")) + + await waitFor(() => { + expect((screen.getByTestId("save-button") as HTMLButtonElement).disabled).toBe(false) + }) + + fireEvent.click(screen.getByTestId("save-button")) + + await waitFor(() => { + const calls = vi.mocked(vscode.postMessage).mock.calls + const updateCall = (calls.find(([msg]: any) => msg?.type === "updateSettings") as any)?.[0] + expect(updateCall).toBeDefined() + // Empty string should become undefined so the host deletes the secret + expect(updateCall.updatedSettings.openRouterImageApiKey).toBeUndefined() + }) + }) + + it("discarding changes resets pending key so save button returns to disabled", async () => { + renderWithApiKeyTrigger() + + await waitFor(() => expect(screen.getByTestId("save-button")).toBeInTheDocument()) + + fireEvent.click(screen.getByTestId("set-api-key")) + + await waitFor(() => { + expect((screen.getByTestId("save-button") as HTMLButtonElement).disabled).toBe(false) + }) + + // Click Done to trigger the unsaved-changes dialog + fireEvent.click(screen.getByText("settings:common.done")) + + await waitFor(() => { + expect(screen.getByText("settings:unsavedChangesDialog.title")).toBeInTheDocument() + }) + + // Confirm discard + fireEvent.click(screen.getByText("settings:unsavedChangesDialog.discardButton")) + + // Save button should be disabled again (pending key cleared) + await waitFor(() => { + expect((screen.getByTestId("save-button") as HTMLButtonElement).disabled).toBe(true) + }) + }) + }) }) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ce7a607d9a8..c4d6426828e 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -258,7 +258,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode codebaseIndexModels: { ollama: {}, openai: {} }, includeDiagnosticMessages: true, maxDiagnosticMessages: 50, - openRouterImageApiKey: "", + openRouterImageApiKeyConfigured: false, openRouterImageGenerationSelectedModel: "", includeCurrentTime: true, includeCurrentCost: true, diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 2a5e74c40d2..8aea8a740e1 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -217,6 +217,7 @@ describe("mergeExtensionState", () => { taskSyncEnabled: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Add the checkpoint timeout property maxReadFileLine: -1, + openRouterImageApiKeyConfigured: false, } const prevState: ExtensionState = { @@ -286,6 +287,7 @@ describe("mergeExtensionState", () => { taskSyncEnabled: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, maxReadFileLine: -1, + openRouterImageApiKeyConfigured: false, } const makeMessage = (ts: number, text: string): ClineMessage => diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 2c83cabbbcb..c069f7cb404 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -803,6 +803,7 @@ "description": "Quan estigui habilitat, Roo pot generar imatges a partir de prompts de text utilitzant els models de generació d'imatges d'OpenRouter. Requereix que es configuri una clau d'API d'OpenRouter.", "openRouterApiKeyLabel": "Clau API d'OpenRouter", "openRouterApiKeyPlaceholder": "Introdueix la teva clau API d'OpenRouter", + "openRouterApiKeyConfiguredPlaceholder": "Clau API configurada — escriu per substituir", "getApiKeyText": "Obté la teva clau API de", "modelSelectionLabel": "Model de generació d'imatges", "modelSelectionDescription": "Selecciona el model per a la generació d'imatges", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index c31d29147d4..7a432b7ac73 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -803,6 +803,7 @@ "providerDescription": "Wähle den Anbieter für die Bildgenerierung.", "openRouterApiKeyLabel": "OpenRouter API-Schlüssel", "openRouterApiKeyPlaceholder": "Gib deinen OpenRouter API-Schlüssel ein", + "openRouterApiKeyConfiguredPlaceholder": "API-Schlüssel konfiguriert — tippe zum Ersetzen", "getApiKeyText": "Hol dir deinen API-Schlüssel von", "modelSelectionLabel": "Bildgenerierungsmodell", "modelSelectionDescription": "Wähle das Modell für die Bildgenerierung aus", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 3b2497aaee7..8fa6e68408a 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -866,6 +866,7 @@ "providerDescription": "Select which provider to use for image generation.", "openRouterApiKeyLabel": "OpenRouter API Key", "openRouterApiKeyPlaceholder": "Enter your OpenRouter API key", + "openRouterApiKeyConfiguredPlaceholder": "API key configured — type to replace", "getApiKeyText": "Get your API key from", "modelSelectionLabel": "Image Generation Model", "modelSelectionDescription": "Select the model to use for image generation", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 6595c4f9079..dde5a6e3ea2 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -803,6 +803,7 @@ "description": "Cuando esté habilitado, Roo puede generar imágenes a partir de prompts de texto usando los modelos de generación de imágenes de OpenRouter. Requiere que se configure una clave de API de OpenRouter.", "openRouterApiKeyLabel": "Clave API de OpenRouter", "openRouterApiKeyPlaceholder": "Introduce tu clave API de OpenRouter", + "openRouterApiKeyConfiguredPlaceholder": "Clave API configurada — escribe para reemplazar", "getApiKeyText": "Obtén tu clave API de", "modelSelectionLabel": "Modelo de generación de imágenes", "modelSelectionDescription": "Selecciona el modelo para la generación de imágenes", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 56337bda14c..a7876a72707 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -803,6 +803,7 @@ "description": "Lorsqu'activé, Roo peut générer des images à partir de prompts textuels en utilisant les modèles de génération d'images d'OpenRouter. Nécessite qu'une clé API OpenRouter soit configurée.", "openRouterApiKeyLabel": "Clé API OpenRouter", "openRouterApiKeyPlaceholder": "Entrez votre clé API OpenRouter", + "openRouterApiKeyConfiguredPlaceholder": "Clé API configurée — tapez pour remplacer", "getApiKeyText": "Obtenez votre clé API depuis", "modelSelectionLabel": "Modèle de génération d'images", "modelSelectionDescription": "Sélectionnez le modèle pour la génération d'images", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index abd334bec09..5a176f475e1 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -803,6 +803,7 @@ "description": "जब सक्षम किया जाता है, तो Roo OpenRouter के छवि निर्माण मॉडल का उपयोग करके टेक्स्ट प्रॉम्प्ट से छवियां उत्पन्न कर सकता है। एक कॉन्फ़िगर किए गए OpenRouter API कुंजी की आवश्यकता होती है।", "openRouterApiKeyLabel": "OpenRouter API कुंजी", "openRouterApiKeyPlaceholder": "अपनी OpenRouter API कुंजी दर्ज करें", + "openRouterApiKeyConfiguredPlaceholder": "API कुंजी कॉन्फ़िगर की गई — बदलने के लिए टाइप करें", "getApiKeyText": "अपनी API कुंजी प्राप्त करें", "modelSelectionLabel": "छवि निर्माण मॉडल", "modelSelectionDescription": "छवि निर्माण के लिए उपयोग करने वाला मॉडल चुनें", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 1ebcf2073b6..41b542b0a0f 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -803,6 +803,7 @@ "providerDescription": "Pilih penyedia untuk menghasilkan gambar.", "openRouterApiKeyLabel": "Kunci API OpenRouter", "openRouterApiKeyPlaceholder": "Masukkan kunci API OpenRouter Anda", + "openRouterApiKeyConfiguredPlaceholder": "Kunci API telah dikonfigurasi — ketik untuk mengganti", "getApiKeyText": "Dapatkan kunci API Anda dari", "modelSelectionLabel": "Model Pembuatan Gambar", "modelSelectionDescription": "Pilih model untuk pembuatan gambar", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 4a0c7161654..89d1666ea5e 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -803,6 +803,7 @@ "description": "Quando abilitato, Roo può generare immagini da prompt di testo utilizzando i modelli di generazione immagini di OpenRouter. Richiede una chiave API OpenRouter configurata.", "openRouterApiKeyLabel": "Chiave API OpenRouter", "openRouterApiKeyPlaceholder": "Inserisci la tua chiave API OpenRouter", + "openRouterApiKeyConfiguredPlaceholder": "Chiave API configurata — digita per sostituire", "getApiKeyText": "Ottieni la tua chiave API da", "modelSelectionLabel": "Modello di generazione immagini", "modelSelectionDescription": "Seleziona il modello per la generazione di immagini", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index b0d921571af..0ff52e24a87 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -803,6 +803,7 @@ "description": "有効にすると、RooはOpenRouterの画像生成モデルを使用してテキストプロンプトから画像を生成できます。OpenRouter APIキーの設定が必要です。", "openRouterApiKeyLabel": "OpenRouter APIキー", "openRouterApiKeyPlaceholder": "OpenRouter APIキーを入力してください", + "openRouterApiKeyConfiguredPlaceholder": "APIキー設定済み — 変更するには入力してください", "getApiKeyText": "APIキーを取得する場所", "modelSelectionLabel": "画像生成モデル", "modelSelectionDescription": "画像生成に使用するモデルを選択", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 88fc8e6d79e..495b6a97d3f 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -803,6 +803,7 @@ "description": "활성화하면 Roo는 OpenRouter의 이미지 생성 모델을 사용하여 텍스트 프롬프트에서 이미지를 생성할 수 있습니다. OpenRouter API 키 구성이 필요합니다.", "openRouterApiKeyLabel": "OpenRouter API 키", "openRouterApiKeyPlaceholder": "OpenRouter API 키를 입력하세요", + "openRouterApiKeyConfiguredPlaceholder": "API 키가 설정됨 — 교체하려면 입력하세요", "getApiKeyText": "API 키를 받을 곳", "modelSelectionLabel": "이미지 생성 모델", "modelSelectionDescription": "이미지 생성에 사용할 모델을 선택하세요", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index fcfad37d376..04d5369892e 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -803,6 +803,7 @@ "description": "Wanneer ingeschakeld, kan Roo afbeeldingen genereren van tekstprompts met behulp van OpenRouter's afbeeldingsgeneratiemodellen. Vereist een geconfigureerde OpenRouter API-sleutel.", "openRouterApiKeyLabel": "OpenRouter API-sleutel", "openRouterApiKeyPlaceholder": "Voer je OpenRouter API-sleutel in", + "openRouterApiKeyConfiguredPlaceholder": "API-sleutel geconfigureerd — typ om te vervangen", "getApiKeyText": "Haal je API-sleutel op van", "modelSelectionLabel": "Afbeeldingsgeneratiemodel", "modelSelectionDescription": "Selecteer het model voor afbeeldingsgeneratie", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index fa48bc6b212..040cb79be44 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -803,6 +803,7 @@ "description": "Gdy włączone, Roo może generować obrazy z promptów tekstowych używając modeli generowania obrazów OpenRouter. Wymaga skonfigurowanego klucza API OpenRouter.", "openRouterApiKeyLabel": "Klucz API OpenRouter", "openRouterApiKeyPlaceholder": "Wprowadź swój klucz API OpenRouter", + "openRouterApiKeyConfiguredPlaceholder": "Klucz API skonfigurowany — wpisz, aby zastąpić", "getApiKeyText": "Uzyskaj swój klucz API od", "modelSelectionLabel": "Model generowania obrazów", "modelSelectionDescription": "Wybierz model do generowania obrazów", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index a8387e05121..ead2e92df2c 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -803,6 +803,7 @@ "description": "Quando habilitado, Roo pode gerar imagens a partir de prompts de texto usando os modelos de geração de imagens do OpenRouter. Requer uma chave de API do OpenRouter configurada.", "openRouterApiKeyLabel": "Chave de API do OpenRouter", "openRouterApiKeyPlaceholder": "Digite sua chave de API do OpenRouter", + "openRouterApiKeyConfiguredPlaceholder": "Chave de API configurada — digite para substituir", "getApiKeyText": "Obtenha sua chave de API de", "modelSelectionLabel": "Modelo de Geração de Imagens", "modelSelectionDescription": "Selecione o modelo para geração de imagens", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index fe24ebee299..e18f5964c56 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -803,6 +803,7 @@ "description": "Когда включено, Roo может генерировать изображения из текстовых запросов, используя модели генерации изображений OpenRouter. Требует настроенный API-ключ OpenRouter.", "openRouterApiKeyLabel": "API-ключ OpenRouter", "openRouterApiKeyPlaceholder": "Введите ваш API-ключ OpenRouter", + "openRouterApiKeyConfiguredPlaceholder": "API-ключ настроен — введите для замены", "getApiKeyText": "Получите ваш API-ключ от", "modelSelectionLabel": "Модель генерации изображений", "modelSelectionDescription": "Выберите модель для генерации изображений", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 7171718f1c5..6cd0b6d3142 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -803,6 +803,7 @@ "description": "Etkinleştirildiğinde, Roo OpenRouter'ın görüntü üretim modellerini kullanarak metin istemlerinden görüntüler üretebilir. Yapılandırılmış bir OpenRouter API anahtarı gerektirir.", "openRouterApiKeyLabel": "OpenRouter API Anahtarı", "openRouterApiKeyPlaceholder": "OpenRouter API anahtarınızı girin", + "openRouterApiKeyConfiguredPlaceholder": "API anahtarı yapılandırıldı — değiştirmek için yazın", "getApiKeyText": "API anahtarınızı alın", "modelSelectionLabel": "Görüntü Üretim Modeli", "modelSelectionDescription": "Görüntü üretimi için kullanılacak modeli seçin", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 95b4f2d6863..ef66ca88a75 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -803,6 +803,7 @@ "description": "Khi được bật, Roo có thể tạo hình ảnh từ lời nhắc văn bản bằng các mô hình tạo hình ảnh của OpenRouter. Yêu cầu khóa API OpenRouter được cấu hình.", "openRouterApiKeyLabel": "Khóa API OpenRouter", "openRouterApiKeyPlaceholder": "Nhập khóa API OpenRouter của bạn", + "openRouterApiKeyConfiguredPlaceholder": "Khóa API đã được cấu hình — nhập để thay thế", "getApiKeyText": "Lấy khóa API của bạn từ", "modelSelectionLabel": "Mô hình tạo hình ảnh", "modelSelectionDescription": "Chọn mô hình để sử dụng cho việc tạo hình ảnh", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index eeba6bb079d..32d024f4e1f 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -803,6 +803,7 @@ "description": "启用后,Roo 可以使用 OpenRouter 的图像生成模型从文本提示生成图像。需要配置 OpenRouter API 密钥。", "openRouterApiKeyLabel": "OpenRouter API 密钥", "openRouterApiKeyPlaceholder": "输入您的 OpenRouter API 密钥", + "openRouterApiKeyConfiguredPlaceholder": "API 密钥已配置 — 输入以替换", "getApiKeyText": "获取您的 API 密钥", "modelSelectionLabel": "图像生成模型", "modelSelectionDescription": "选择用于图像生成的模型", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 9f4241c3dd9..093eb049190 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -813,6 +813,7 @@ "providerDescription": "選擇用於影像生成的供應商。", "openRouterApiKeyLabel": "OpenRouter API 金鑰", "openRouterApiKeyPlaceholder": "輸入您的 OpenRouter API 金鑰", + "openRouterApiKeyConfiguredPlaceholder": "API 金鑰已設定 — 輸入以取代", "getApiKeyText": "取得 API 金鑰請前往", "modelSelectionLabel": "影像生成模型", "modelSelectionDescription": "選擇用於影像生成的模型",