Skip to content

Commit 2959b90

Browse files
committed
fix: invalidate view-local state after reset and import
1 parent ba853ed commit 2959b90

5 files changed

Lines changed: 305 additions & 1 deletion

File tree

src/core/config/__tests__/importExport.spec.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,158 @@ describe("importExport", () => {
836836
expect(mockProvider.settingsImportedAt).toBeUndefined()
837837
})
838838

839+
it("should call broadcastResetToAllInstances after successful import when available", async () => {
840+
const filePath = "/mock/path/settings.json"
841+
const mockFileContent = JSON.stringify({
842+
providerProfiles: {
843+
currentApiConfigName: "valid-profile",
844+
apiConfigs: {
845+
"valid-profile": {
846+
apiProvider: "openai" as ProviderName,
847+
apiKey: "test-key",
848+
id: "valid-id",
849+
},
850+
},
851+
},
852+
globalSettings: { mode: "code" },
853+
})
854+
855+
;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
856+
;(fs.access as Mock).mockResolvedValue(undefined)
857+
mockProviderSettingsManager.export.mockResolvedValue({
858+
currentApiConfigName: "default",
859+
apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
860+
})
861+
mockProviderSettingsManager.listConfig.mockResolvedValue([
862+
{ name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName },
863+
])
864+
865+
const mockProvider = {
866+
settingsImportedAt: 0,
867+
postStateToWebview: vi.fn().mockResolvedValue(undefined),
868+
broadcastResetToAllInstances: vi.fn().mockResolvedValue(undefined),
869+
}
870+
871+
await importSettingsWithFeedback(
872+
{
873+
providerSettingsManager: mockProviderSettingsManager,
874+
contextProxy: mockContextProxy,
875+
customModesManager: mockCustomModesManager,
876+
provider: mockProvider,
877+
},
878+
filePath,
879+
)
880+
881+
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
882+
expect(mockProvider.broadcastResetToAllInstances).toHaveBeenCalledTimes(1)
883+
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
884+
expect.stringContaining("settings_imported"),
885+
)
886+
})
887+
888+
it("should skip broadcastResetToAllInstances when callback is missing", async () => {
889+
const filePath = "/mock/path/settings.json"
890+
const mockFileContent = JSON.stringify({
891+
providerProfiles: {
892+
currentApiConfigName: "valid-profile",
893+
apiConfigs: {
894+
"valid-profile": {
895+
apiProvider: "openai" as ProviderName,
896+
apiKey: "test-key",
897+
id: "valid-id",
898+
},
899+
},
900+
},
901+
globalSettings: { mode: "code" },
902+
})
903+
904+
;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
905+
;(fs.access as Mock).mockResolvedValue(undefined)
906+
mockProviderSettingsManager.export.mockResolvedValue({
907+
currentApiConfigName: "default",
908+
apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
909+
})
910+
mockProviderSettingsManager.listConfig.mockResolvedValue([
911+
{ name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName },
912+
])
913+
914+
const mockProvider = {
915+
settingsImportedAt: 0,
916+
postStateToWebview: vi.fn().mockResolvedValue(undefined),
917+
}
918+
919+
await importSettingsWithFeedback(
920+
{
921+
providerSettingsManager: mockProviderSettingsManager,
922+
contextProxy: mockContextProxy,
923+
customModesManager: mockCustomModesManager,
924+
provider: mockProvider,
925+
},
926+
filePath,
927+
)
928+
929+
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
930+
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
931+
expect.stringContaining("settings_imported"),
932+
)
933+
})
934+
935+
it("should keep successful import result when broadcastResetToAllInstances throws", async () => {
936+
const filePath = "/mock/path/settings.json"
937+
const mockFileContent = JSON.stringify({
938+
providerProfiles: {
939+
currentApiConfigName: "valid-profile",
940+
apiConfigs: {
941+
"valid-profile": {
942+
apiProvider: "openai" as ProviderName,
943+
apiKey: "test-key",
944+
id: "valid-id",
945+
},
946+
},
947+
},
948+
globalSettings: { mode: "code" },
949+
})
950+
951+
;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
952+
;(fs.access as Mock).mockResolvedValue(undefined)
953+
mockProviderSettingsManager.export.mockResolvedValue({
954+
currentApiConfigName: "default",
955+
apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
956+
})
957+
mockProviderSettingsManager.listConfig.mockResolvedValue([
958+
{ name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName },
959+
])
960+
961+
const broadcastError = new Error("broadcast failed")
962+
const mockProvider = {
963+
settingsImportedAt: 0,
964+
postStateToWebview: vi.fn().mockResolvedValue(undefined),
965+
broadcastResetToAllInstances: vi.fn().mockRejectedValue(broadcastError),
966+
}
967+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
968+
969+
await importSettingsWithFeedback(
970+
{
971+
providerSettingsManager: mockProviderSettingsManager,
972+
contextProxy: mockContextProxy,
973+
customModesManager: mockCustomModesManager,
974+
provider: mockProvider,
975+
},
976+
filePath,
977+
)
978+
979+
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
980+
expect(mockProvider.broadcastResetToAllInstances).toHaveBeenCalledTimes(1)
981+
expect(consoleWarnSpy).toHaveBeenCalledWith(
982+
expect.stringContaining("Failed to broadcast reset after settings import"),
983+
)
984+
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
985+
expect.stringContaining("settings_imported"),
986+
)
987+
988+
consoleWarnSpy.mockRestore()
989+
})
990+
839991
it("should handle multiple profiles with mixed valid and invalid providers", async () => {
840992
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
841993

src/core/config/importExport.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type ImportWithProviderOptions = ImportOptions & {
3636
provider: {
3737
settingsImportedAt?: number
3838
postStateToWebview: () => Promise<void>
39+
broadcastResetToAllInstances?(): Promise<void>
3940
}
4041
}
4142

@@ -385,6 +386,18 @@ export const importSettingsWithFeedback = async (
385386
if (result.success) {
386387
provider.settingsImportedAt = Date.now()
387388
await provider.postStateToWebview()
389+
390+
// Broadcast invalidation to all other live ClineProvider instances so parallel
391+
// tabs don't keep stale view-local state after a settings import.
392+
try {
393+
if (provider.broadcastResetToAllInstances) {
394+
await provider.broadcastResetToAllInstances()
395+
}
396+
} catch (error) {
397+
// Log but do not fail the import if broadcast fails — the import itself succeeded.
398+
console.warn(`Failed to broadcast reset after settings import: ${error}`)
399+
}
400+
388401
provider.settingsImportedAt = undefined
389402
const warnings = "warnings" in result ? result.warnings : undefined
390403

src/core/webview/ClineProvider.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3056,6 +3056,21 @@ export class ClineProvider
30563056
this.viewLocalState = {}
30573057
}
30583058

3059+
/**
3060+
* Broadcast a reset event to all other live ClineProvider instances, clearing their
3061+
* view-local state caches and posting updated state so parallel tabs stay in sync.
3062+
* Also exposed for use by importSettingsWithFeedback (via broadcastResetToAllInstances callback).
3063+
*/
3064+
async broadcastResetToAllInstances(): Promise<void> {
3065+
const allInstances = ClineProvider.getAllInstances()
3066+
for (const instance of allInstances) {
3067+
if (instance !== this) {
3068+
instance._clearViewLocalState()
3069+
await instance.postStateToWebview()
3070+
}
3071+
}
3072+
}
3073+
30593074
// dev
30603075

30613076
async resetState() {
@@ -3086,6 +3101,11 @@ export class ClineProvider
30863101
await this.providerSettingsManager.resetAllConfigs()
30873102
await this.customModesManager.resetCustomModes()
30883103
await this.removeClineFromStack()
3104+
3105+
// Clear this instance and all other live instances so parallel tabs don't keep stale state.
3106+
this._clearViewLocalState()
3107+
await this.broadcastResetToAllInstances()
3108+
30893109
await this.postStateToWebview()
30903110
await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
30913111
}

src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,11 @@ vi.mock("../../config/ContextProxy", () => {
290290
)
291291
})
292292
setProviderSettings = vi.fn().mockImplementation((settings: Record<string, any>) => this.setValues(settings))
293+
resetAllState = vi.fn().mockImplementation(() => {
294+
return Promise.all(
295+
Object.keys(this.context?.globalState ?? {}).map((key) => this.setValue(key, undefined)),
296+
).then(() => undefined)
297+
})
293298
}
294299
return { ContextProxy: MockContextProxy }
295300
})
@@ -475,6 +480,7 @@ vi.mock("../../config/ProviderSettingsManager", () => ({
475480
saveConfig: vi.fn().mockResolvedValue("test-id"),
476481
listConfig: vi.fn().mockResolvedValue([]),
477482
getProfile: vi.fn().mockResolvedValue({}),
483+
resetAllConfigs: vi.fn().mockResolvedValue(undefined),
478484
activateProfile: vi.fn().mockImplementation(async (args: { name?: string; id?: string }) => ({
479485
name: args.name ?? "default",
480486
id: args.id ?? "test-id",
@@ -492,6 +498,7 @@ vi.mock("../../config/CustomModesManager", () => ({
492498
return {
493499
updateCustomMode: vi.fn().mockResolvedValue(undefined),
494500
getCustomModes: vi.fn().mockResolvedValue([]),
501+
resetCustomModes: vi.fn().mockResolvedValue(undefined),
495502
dispose: vi.fn(),
496503
}
497504
}),
@@ -1071,4 +1078,107 @@ describe("ClineProvider - Parallel Mode Support", () => {
10711078
await provider.dispose()
10721079
})
10731080
})
1081+
1082+
describe("broadcastResetToAllInstances", () => {
1083+
it("should clear viewLocalState in other instances and post updated state", async () => {
1084+
const provider1 = new ClineProvider(
1085+
mockContext,
1086+
mockOutputChannel,
1087+
"sidebar",
1088+
new ContextProxy(mockContext),
1089+
)
1090+
const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext))
1091+
const postStateToWebview2 = vi.spyOn(provider2, "postStateToWebview").mockResolvedValue(undefined)
1092+
1093+
await (provider1 as any).saveViewState("mode", "architect")
1094+
await (provider2 as any).saveViewState("mode", "debugger")
1095+
1096+
await provider1.broadcastResetToAllInstances()
1097+
1098+
expect((provider1 as any).viewLocalState.mode).toBe("architect")
1099+
expect((provider2 as any).viewLocalState).toEqual({})
1100+
expect(postStateToWebview2).toHaveBeenCalledTimes(1)
1101+
1102+
await provider1.dispose()
1103+
await provider2.dispose()
1104+
})
1105+
1106+
it("should be a no-op for a single live instance", async () => {
1107+
const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
1108+
const postStateToWebview = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
1109+
1110+
await (provider as any).saveViewState("mode", "architect")
1111+
1112+
await provider.broadcastResetToAllInstances()
1113+
1114+
expect((provider as any).viewLocalState.mode).toBe("architect")
1115+
expect(postStateToWebview).not.toHaveBeenCalled()
1116+
1117+
await provider.dispose()
1118+
})
1119+
1120+
it("should broadcast to multiple other live instances", async () => {
1121+
const provider1 = new ClineProvider(
1122+
mockContext,
1123+
mockOutputChannel,
1124+
"sidebar",
1125+
new ContextProxy(mockContext),
1126+
)
1127+
const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext))
1128+
const provider3 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext))
1129+
const postStateToWebview1 = vi.spyOn(provider1, "postStateToWebview").mockResolvedValue(undefined)
1130+
const postStateToWebview2 = vi.spyOn(provider2, "postStateToWebview").mockResolvedValue(undefined)
1131+
const postStateToWebview3 = vi.spyOn(provider3, "postStateToWebview").mockResolvedValue(undefined)
1132+
1133+
await (provider1 as any).saveViewState("mode", "code")
1134+
await (provider2 as any).saveViewState("mode", "architect")
1135+
await (provider3 as any).saveViewState("currentApiConfigName", "profile-c")
1136+
1137+
await provider1.broadcastResetToAllInstances()
1138+
1139+
expect((provider1 as any).viewLocalState.mode).toBe("code")
1140+
expect((provider2 as any).viewLocalState).toEqual({})
1141+
expect((provider3 as any).viewLocalState).toEqual({})
1142+
expect(postStateToWebview1).not.toHaveBeenCalled()
1143+
expect(postStateToWebview2).toHaveBeenCalledTimes(1)
1144+
expect(postStateToWebview3).toHaveBeenCalledTimes(1)
1145+
1146+
await provider1.dispose()
1147+
await provider2.dispose()
1148+
await provider3.dispose()
1149+
})
1150+
})
1151+
1152+
describe("resetState cross-tab invalidation", () => {
1153+
it("should clear caller and other live instances during resetState flow", async () => {
1154+
const provider1 = new ClineProvider(
1155+
mockContext,
1156+
mockOutputChannel,
1157+
"sidebar",
1158+
new ContextProxy(mockContext),
1159+
)
1160+
const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext))
1161+
const postStateToWebview1 = vi.spyOn(provider1, "postStateToWebview").mockResolvedValue(undefined)
1162+
const postStateToWebview2 = vi.spyOn(provider2, "postStateToWebview").mockResolvedValue(undefined)
1163+
vi.spyOn(provider1, "postMessageToWebview").mockResolvedValue(true)
1164+
vi.spyOn(provider1 as any, "removeClineFromStack").mockResolvedValue(undefined)
1165+
;(vscode.window.showInformationMessage as any).mockImplementation(async (...args: any[]) => args[2])
1166+
1167+
await (provider1 as any).saveViewState("mode", "architect")
1168+
await (provider2 as any).saveViewState("currentApiConfigName", "profile-b")
1169+
1170+
await provider1.resetState()
1171+
1172+
expect((provider1 as any).viewLocalState).toEqual({})
1173+
expect((provider2 as any).viewLocalState).toEqual({})
1174+
expect((provider1.contextProxy as any).resetAllState).toHaveBeenCalledTimes(1)
1175+
expect((provider1.providerSettingsManager as any).resetAllConfigs).toHaveBeenCalledTimes(1)
1176+
expect((provider1.customModesManager as any).resetCustomModes).toHaveBeenCalledTimes(1)
1177+
expect(postStateToWebview1).toHaveBeenCalledTimes(1)
1178+
expect(postStateToWebview2).toHaveBeenCalledTimes(1)
1179+
1180+
await provider1.dispose()
1181+
await provider2.dispose()
1182+
})
1183+
})
10741184
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -914,7 +914,16 @@ export const webviewMessageHandler = async (
914914
providerSettingsManager: provider.providerSettingsManager,
915915
contextProxy: provider.contextProxy,
916916
customModesManager: provider.customModesManager,
917-
provider: provider,
917+
provider: {
918+
get settingsImportedAt() {
919+
return provider.settingsImportedAt
920+
},
921+
set settingsImportedAt(value) {
922+
provider.settingsImportedAt = value
923+
},
924+
postStateToWebview: () => provider.postStateToWebview(),
925+
broadcastResetToAllInstances: () => provider.broadcastResetToAllInstances(),
926+
},
918927
})
919928

920929
break

0 commit comments

Comments
 (0)