Skip to content
Open
5 changes: 5 additions & 0 deletions packages/types/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import { GLOBAL_STATE_KEYS } from "../index.js"

describe("GLOBAL_STATE_KEYS", () => {
it("should contain registered durable per-view state", () => {
expect(GLOBAL_STATE_KEYS).toContain("viewStates")
})

it("should contain provider settings keys", () => {
expect(GLOBAL_STATE_KEYS).toContain("autoApprovalEnabled")
})
Expand All @@ -13,6 +17,7 @@ describe("GLOBAL_STATE_KEYS", () => {

it("should not contain secret state keys", () => {
expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey")
expect(GLOBAL_STATE_KEYS).not.toContain("apiKey")
})

it("should contain OpenAI Compatible base URL setting", () => {
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60
*/
export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15

/**
* Persisted non-secret selections for a stable webview instance.
*/
export const viewStateSchema = z.object({
mode: z.string().optional(),
currentApiConfigName: z.string().optional(),
updatedAt: z.number().optional(),
})

/**
* GlobalSettings
*/
Expand All @@ -105,6 +114,7 @@ export const globalSettingsSchema = z.object({
currentApiConfigName: z.string().optional(),
listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(),
pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(),
viewStates: z.record(z.string(), viewStateSchema).optional(),

lastShownAnnouncementId: z.string().optional(),
customInstructions: z.string().optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ export interface WebviewMessage {
| "openRuleFile"
| "openRulesDirectory"
text?: string
viewStateId?: string
taskId?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
Expand Down
152 changes: 152 additions & 0 deletions src/core/config/__tests__/importExport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,158 @@ describe("importExport", () => {
expect(mockProvider.settingsImportedAt).toBeUndefined()
})

it("should call broadcastResetToAllInstances after successful import when available", async () => {
const filePath = "/mock/path/settings.json"
const mockFileContent = JSON.stringify({
providerProfiles: {
currentApiConfigName: "valid-profile",
apiConfigs: {
"valid-profile": {
apiProvider: "openai" as ProviderName,
apiKey: "test-key",
id: "valid-id",
},
},
},
globalSettings: { mode: "code" },
})

;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
;(fs.access as Mock).mockResolvedValue(undefined)
mockProviderSettingsManager.export.mockResolvedValue({
currentApiConfigName: "default",
apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
})
mockProviderSettingsManager.listConfig.mockResolvedValue([
{ name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName },
])

const mockProvider = {
settingsImportedAt: 0,
postStateToWebview: vi.fn().mockResolvedValue(undefined),
broadcastResetToAllInstances: vi.fn().mockResolvedValue(undefined),
}

await importSettingsWithFeedback(
{
providerSettingsManager: mockProviderSettingsManager,
contextProxy: mockContextProxy,
customModesManager: mockCustomModesManager,
provider: mockProvider,
},
filePath,
)

expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
expect(mockProvider.broadcastResetToAllInstances).toHaveBeenCalledTimes(1)
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
expect.stringContaining("settings_imported"),
)
})

it("should skip broadcastResetToAllInstances when callback is missing", async () => {
const filePath = "/mock/path/settings.json"
const mockFileContent = JSON.stringify({
providerProfiles: {
currentApiConfigName: "valid-profile",
apiConfigs: {
"valid-profile": {
apiProvider: "openai" as ProviderName,
apiKey: "test-key",
id: "valid-id",
},
},
},
globalSettings: { mode: "code" },
})

;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
;(fs.access as Mock).mockResolvedValue(undefined)
mockProviderSettingsManager.export.mockResolvedValue({
currentApiConfigName: "default",
apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
})
mockProviderSettingsManager.listConfig.mockResolvedValue([
{ name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName },
])

const mockProvider = {
settingsImportedAt: 0,
postStateToWebview: vi.fn().mockResolvedValue(undefined),
}

await importSettingsWithFeedback(
{
providerSettingsManager: mockProviderSettingsManager,
contextProxy: mockContextProxy,
customModesManager: mockCustomModesManager,
provider: mockProvider,
},
filePath,
)

expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
expect.stringContaining("settings_imported"),
)
})

it("should keep successful import result when broadcastResetToAllInstances throws", async () => {
const filePath = "/mock/path/settings.json"
const mockFileContent = JSON.stringify({
providerProfiles: {
currentApiConfigName: "valid-profile",
apiConfigs: {
"valid-profile": {
apiProvider: "openai" as ProviderName,
apiKey: "test-key",
id: "valid-id",
},
},
},
globalSettings: { mode: "code" },
})

;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
;(fs.access as Mock).mockResolvedValue(undefined)
mockProviderSettingsManager.export.mockResolvedValue({
currentApiConfigName: "default",
apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
})
mockProviderSettingsManager.listConfig.mockResolvedValue([
{ name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName },
])

const broadcastError = new Error("broadcast failed")
const mockProvider = {
settingsImportedAt: 0,
postStateToWebview: vi.fn().mockResolvedValue(undefined),
broadcastResetToAllInstances: vi.fn().mockRejectedValue(broadcastError),
}
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})

await importSettingsWithFeedback(
{
providerSettingsManager: mockProviderSettingsManager,
contextProxy: mockContextProxy,
customModesManager: mockCustomModesManager,
provider: mockProvider,
},
filePath,
)

expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
expect(mockProvider.broadcastResetToAllInstances).toHaveBeenCalledTimes(1)
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to broadcast reset after settings import"),
)
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
expect.stringContaining("settings_imported"),
)

consoleWarnSpy.mockRestore()
})

it("should handle multiple profiles with mixed valid and invalid providers", async () => {
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])

Expand Down
13 changes: 13 additions & 0 deletions src/core/config/importExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type ImportWithProviderOptions = ImportOptions & {
provider: {
settingsImportedAt?: number
postStateToWebview: () => Promise<void>
broadcastResetToAllInstances?(): Promise<void>
}
}

Expand Down Expand Up @@ -385,6 +386,18 @@ export const importSettingsWithFeedback = async (
if (result.success) {
provider.settingsImportedAt = Date.now()
await provider.postStateToWebview()

// Broadcast invalidation to all other live ClineProvider instances so parallel
// tabs don't keep stale view-local state after a settings import.
try {
if (provider.broadcastResetToAllInstances) {
await provider.broadcastResetToAllInstances()
}
} catch (error) {
// Log but do not fail the import if broadcast fails — the import itself succeeded.
console.warn(`Failed to broadcast reset after settings import: ${error}`)
}

provider.settingsImportedAt = undefined
const warnings = "warnings" in result ? result.warnings : undefined

Expand Down
Loading
Loading