Skip to content

Commit b9e8fb7

Browse files
committed
fix(webview): invalidate live sibling view state on reset and settings import
- ClineProvider: new broadcastResetToAllInstances() clears each live instance's view-local cache and issues the single global contextProxy setValue("viewStates", undefined) write (single write-queue clear; no secrets involved, no prune-cap regression). - resetState: awaits broadcastResetToAllInstances() before the final postStateToWebview so parallel tabs do not keep stale durable/in-memory per-view state. - importExport: ImportWithProviderOptions.provider gains optional broadcastResetToAllInstances?(); importSettingsWithFeedback calls it in a guarded try/catch (log-only) after a successful import, so a failing broadcast never fails the import. - importExport spec: 3 new tests (broadcast called when available / skipped when missing / import result preserved when broadcast throws, console.warn asserted; the skip test also asserts the broadcast-failure warn is NOT reached). Provider identifiers use providerIdentifiers.* per the zoo/no-raw-provider-identifiers rule (lint-required adaptation from #981's raw-string casts; no semantic change). - parallelMode spec: appends the CS source-of-record describes (multi-instance isolation, _clearViewLocalState) — 5 new tests. - ClineProvider spec: forward fix of the F3 resetState sentinel (F4's global viewStates clear removes the key; the F3-era toEqual({}) expectation is replaced by toBeUndefined()) plus a new cross-instance resetState test pinning the multi-instance broadcast path (sibling view-local cache cleared; sibling and caller each post state exactly once). - webviewMessageHandler.ts was NOT edited: the importSettings case already passes the full ClineProvider, which structurally satisfies the extended provider type and reaches the real broadcast method — #981's structural wrapper hunk is redundant in this stack. Upstream: #980 / PR #981 (vps2 F4)
1 parent f9233a4 commit b9e8fb7

5 files changed

Lines changed: 400 additions & 1 deletion

File tree

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

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,220 @@ describe("importExport", () => {
10011001
expect(mockProvider.settingsImportedAt).toBeUndefined()
10021002
})
10031003

1004+
it("should call broadcastResetToAllInstances after successful import when available", async () => {
1005+
const filePath = "/mock/path/settings.json"
1006+
const mockFileContent = JSON.stringify({
1007+
providerProfiles: {
1008+
currentApiConfigName: "valid-profile",
1009+
apiConfigs: {
1010+
"valid-profile": {
1011+
apiProvider: providerIdentifiers.openai,
1012+
apiKey: "test-key",
1013+
id: "valid-id",
1014+
},
1015+
},
1016+
},
1017+
globalSettings: { mode: "code" },
1018+
})
1019+
1020+
;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
1021+
;(fs.access as Mock).mockResolvedValue(undefined)
1022+
mockProviderSettingsManager.export.mockResolvedValue({
1023+
currentApiConfigName: "default",
1024+
apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } },
1025+
})
1026+
mockProviderSettingsManager.listConfig.mockResolvedValue([
1027+
{ name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai },
1028+
])
1029+
1030+
const mockProvider = {
1031+
settingsImportedAt: 0,
1032+
postStateToWebview: vi.fn().mockResolvedValue(undefined),
1033+
broadcastResetToAllInstances: vi.fn().mockResolvedValue(undefined),
1034+
}
1035+
1036+
await importSettingsWithFeedback(
1037+
{
1038+
providerSettingsManager: mockProviderSettingsManager,
1039+
contextProxy: mockContextProxy,
1040+
customModesManager: mockCustomModesManager,
1041+
provider: mockProvider,
1042+
},
1043+
filePath,
1044+
)
1045+
1046+
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
1047+
expect(mockProvider.broadcastResetToAllInstances).toHaveBeenCalledTimes(1)
1048+
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
1049+
expect.stringContaining("settings_imported"),
1050+
)
1051+
})
1052+
1053+
it("posts the initiating provider state only after the reset broadcast completes", async () => {
1054+
const filePath = "/mock/path/settings.json"
1055+
const mockFileContent = JSON.stringify({
1056+
providerProfiles: {
1057+
currentApiConfigName: "valid-profile",
1058+
apiConfigs: {
1059+
"valid-profile": {
1060+
apiProvider: providerIdentifiers.openai,
1061+
apiKey: "test-key",
1062+
id: "valid-id",
1063+
},
1064+
},
1065+
},
1066+
globalSettings: { mode: "code" },
1067+
})
1068+
1069+
;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
1070+
;(fs.access as Mock).mockResolvedValue(undefined)
1071+
mockProviderSettingsManager.export.mockResolvedValue({
1072+
currentApiConfigName: "default",
1073+
apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } },
1074+
})
1075+
mockProviderSettingsManager.listConfig.mockResolvedValue([
1076+
{ name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai },
1077+
])
1078+
1079+
const callOrder: string[] = []
1080+
const mockProvider = {
1081+
settingsImportedAt: 0,
1082+
postStateToWebview: vi.fn().mockImplementation(async () => {
1083+
callOrder.push("post")
1084+
}),
1085+
broadcastResetToAllInstances: vi.fn().mockImplementation(async () => {
1086+
callOrder.push("broadcast")
1087+
}),
1088+
}
1089+
1090+
await importSettingsWithFeedback(
1091+
{
1092+
providerSettingsManager: mockProviderSettingsManager,
1093+
contextProxy: mockContextProxy,
1094+
customModesManager: mockCustomModesManager,
1095+
provider: mockProvider,
1096+
},
1097+
filePath,
1098+
)
1099+
1100+
// The broadcast clears the durable view state, so the initiating provider's
1101+
// webview post must run after it — otherwise the webview keeps the stale
1102+
// pre-import per-view mode/profile.
1103+
expect(callOrder).toEqual(["broadcast", "post"])
1104+
})
1105+
1106+
it("should skip broadcastResetToAllInstances when callback is missing", async () => {
1107+
const filePath = "/mock/path/settings.json"
1108+
const mockFileContent = JSON.stringify({
1109+
providerProfiles: {
1110+
currentApiConfigName: "valid-profile",
1111+
apiConfigs: {
1112+
"valid-profile": {
1113+
apiProvider: providerIdentifiers.openai,
1114+
apiKey: "test-key",
1115+
id: "valid-id",
1116+
},
1117+
},
1118+
},
1119+
globalSettings: { mode: "code" },
1120+
})
1121+
1122+
;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
1123+
;(fs.access as Mock).mockResolvedValue(undefined)
1124+
mockProviderSettingsManager.export.mockResolvedValue({
1125+
currentApiConfigName: "default",
1126+
apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } },
1127+
})
1128+
mockProviderSettingsManager.listConfig.mockResolvedValue([
1129+
{ name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai },
1130+
])
1131+
1132+
const mockProvider = {
1133+
settingsImportedAt: 0,
1134+
postStateToWebview: vi.fn().mockResolvedValue(undefined),
1135+
}
1136+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
1137+
1138+
await importSettingsWithFeedback(
1139+
{
1140+
providerSettingsManager: mockProviderSettingsManager,
1141+
contextProxy: mockContextProxy,
1142+
customModesManager: mockCustomModesManager,
1143+
provider: mockProvider,
1144+
},
1145+
filePath,
1146+
)
1147+
1148+
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
1149+
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
1150+
expect.stringContaining("settings_imported"),
1151+
)
1152+
// A missing callback must not reach the broadcast guard's failure path.
1153+
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
1154+
expect.stringContaining("Failed to broadcast reset after settings import"),
1155+
)
1156+
consoleWarnSpy.mockRestore()
1157+
})
1158+
1159+
it("should keep successful import result when broadcastResetToAllInstances throws", async () => {
1160+
const filePath = "/mock/path/settings.json"
1161+
const mockFileContent = JSON.stringify({
1162+
providerProfiles: {
1163+
currentApiConfigName: "valid-profile",
1164+
apiConfigs: {
1165+
"valid-profile": {
1166+
apiProvider: providerIdentifiers.openai,
1167+
apiKey: "test-key",
1168+
id: "valid-id",
1169+
},
1170+
},
1171+
},
1172+
globalSettings: { mode: "code" },
1173+
})
1174+
1175+
;(fs.readFile as Mock).mockResolvedValue(mockFileContent)
1176+
;(fs.access as Mock).mockResolvedValue(undefined)
1177+
mockProviderSettingsManager.export.mockResolvedValue({
1178+
currentApiConfigName: "default",
1179+
apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } },
1180+
})
1181+
mockProviderSettingsManager.listConfig.mockResolvedValue([
1182+
{ name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai },
1183+
])
1184+
1185+
const broadcastError = new Error("broadcast failed")
1186+
const mockProvider = {
1187+
settingsImportedAt: 0,
1188+
postStateToWebview: vi.fn().mockResolvedValue(undefined),
1189+
broadcastResetToAllInstances: vi.fn().mockRejectedValue(broadcastError),
1190+
}
1191+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
1192+
1193+
await importSettingsWithFeedback(
1194+
{
1195+
providerSettingsManager: mockProviderSettingsManager,
1196+
contextProxy: mockContextProxy,
1197+
customModesManager: mockCustomModesManager,
1198+
provider: mockProvider,
1199+
},
1200+
filePath,
1201+
)
1202+
1203+
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
1204+
expect(mockProvider.broadcastResetToAllInstances).toHaveBeenCalledTimes(1)
1205+
expect(consoleWarnSpy).toHaveBeenCalledWith(
1206+
expect.stringContaining("Failed to broadcast reset after settings import"),
1207+
)
1208+
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
1209+
expect.stringContaining("settings_imported"),
1210+
)
1211+
// The rejected broadcast must not leave the import timestamp dangling: the
1212+
// guarded catch falls through to the cleanup reset.
1213+
expect(mockProvider.settingsImportedAt).toBeUndefined()
1214+
1215+
consoleWarnSpy.mockRestore()
1216+
})
1217+
10041218
it("should handle multiple profiles with mixed valid and invalid providers", async () => {
10051219
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
10061220

src/core/config/importExport.ts

Lines changed: 15 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

@@ -392,7 +393,21 @@ export const importSettingsWithFeedback = async (
392393

393394
if (result.success) {
394395
provider.settingsImportedAt = Date.now()
396+
397+
// Broadcast invalidation to all other live ClineProvider instances first, so the
398+
// initiating provider's post below reflects the cleared view state rather than the
399+
// stale pre-import per-view mode/profile.
400+
try {
401+
if (provider.broadcastResetToAllInstances) {
402+
await provider.broadcastResetToAllInstances()
403+
}
404+
} catch (error) {
405+
// Log but do not fail the import if broadcast fails — the import itself succeeded.
406+
console.warn(`Failed to broadcast reset after settings import: ${error}`)
407+
}
408+
395409
await provider.postStateToWebview()
410+
396411
provider.settingsImportedAt = undefined
397412
const warnings = "warnings" in result ? result.warnings : undefined
398413

src/core/webview/ClineProvider.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3750,6 +3750,23 @@ export class ClineProvider
37503750
this.viewLocalState = {}
37513751
}
37523752

3753+
/**
3754+
* Broadcast a reset/import invalidation to all live ClineProvider instances, clearing
3755+
* both in-memory view-local caches and durable per-view selections so stale view state
3756+
* cannot mask imported/reset shared state after reload.
3757+
*/
3758+
async broadcastResetToAllInstances(): Promise<void> {
3759+
const allInstances = ClineProvider.getAllInstances()
3760+
for (const instance of allInstances) {
3761+
instance._clearViewLocalState()
3762+
await instance.contextProxy.setValue("viewStates", undefined)
3763+
3764+
if (instance !== this) {
3765+
await instance.postStateToWebview()
3766+
}
3767+
}
3768+
}
3769+
37533770
// dev
37543771

37553772
async resetState() {
@@ -3787,6 +3804,10 @@ export class ClineProvider
37873804
await this.providerSettingsManager.resetAllConfigs()
37883805
await this.customModesManager.resetCustomModes()
37893806
await this.removeClineFromStack()
3807+
3808+
// Clear durable and in-memory per-view state across live instances so parallel tabs don't keep stale state.
3809+
await this.broadcastResetToAllInstances()
3810+
37903811
await this.postStateToWebview()
37913812
await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
37923813
}

0 commit comments

Comments
 (0)