Skip to content

Commit e683297

Browse files
committed
fix(webview): preserve isolated view state writes
1 parent 0b670a8 commit e683297

3 files changed

Lines changed: 99 additions & 16 deletions

File tree

src/core/webview/ClineProvider.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -459,9 +459,10 @@ export class ClineProvider
459459
}
460460

461461
private async savePersistedViewState(values: Partial<PersistedViewState>): Promise<void> {
462+
const viewStateId = this.viewStateId
462463
const write = ClineProvider.persistedViewStateWriteQueue.then(async () => {
463464
const states = this.getPersistedViewStates({ fresh: true })
464-
const current = states[this.viewStateId] ?? {}
465+
const current = states[viewStateId] ?? {}
465466
const next: PersistedViewState = { ...current }
466467

467468
if ("mode" in values) {
@@ -481,10 +482,10 @@ export class ClineProvider
481482
}
482483

483484
if (!next.mode && !next.currentApiConfigName) {
484-
delete states[this.viewStateId]
485+
delete states[viewStateId]
485486
} else {
486487
next.updatedAt = values.updatedAt ?? Date.now()
487-
states[this.viewStateId] = next
488+
states[viewStateId] = next
488489
}
489490

490491
await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states))
@@ -565,18 +566,18 @@ export class ClineProvider
565566
* Save a single view-local state value. Only non-secret selections are persisted durably.
566567
*/
567568
private async saveViewState(key: keyof ExtensionState, value: any): Promise<void> {
568-
if (value === undefined || value === null) {
569-
delete this.viewLocalState[key]
570-
} else {
571-
this.viewLocalState[key] = value
572-
}
573-
574569
if (key === "mode") {
575570
await this.savePersistedViewState({ mode: value })
576571
} else if (key === "currentApiConfigName") {
577572
await this.savePersistedViewState({ currentApiConfigName: value })
578573
}
579574

575+
if (value === undefined || value === null) {
576+
delete this.viewLocalState[key]
577+
} else {
578+
this.viewLocalState[key] = value
579+
}
580+
580581
this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`)
581582
}
582583

@@ -906,6 +907,15 @@ export class ClineProvider
906907
this.customModesManager?.dispose()
907908
this.taskHistoryStore.dispose()
908909
this.flushGlobalStateWriteThrough()
910+
if (this.renderContext === "editor") {
911+
try {
912+
await this.clearPersistedViewState()
913+
} catch (error) {
914+
this.log(
915+
`[dispose] Failed to clear persisted view state for ${this.viewStateId}: ${error instanceof Error ? error.message : String(error)}`,
916+
)
917+
}
918+
}
909919
this.log("Disposed all disposables")
910920
ClineProvider.activeInstances.delete(this)
911921

@@ -3150,10 +3160,13 @@ export class ClineProvider
31503160
return acc
31513161
}, {} as ProviderSettings)
31523162

3153-
this.viewLocalState.apiConfiguration = {
3154-
...(this.viewLocalState.apiConfiguration ?? {}),
3155-
...providerSettingsUpdate,
3156-
}
3163+
this.viewLocalState.apiConfiguration =
3164+
"apiProvider" in providerSettingsUpdate
3165+
? providerSettingsUpdate
3166+
: {
3167+
...(this.viewLocalState.apiConfiguration ?? {}),
3168+
...providerSettingsUpdate,
3169+
}
31573170
}
31583171
}
31593172

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

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ vi.mock("vscode", () => ({
198198
showErrorMessage: vi.fn(),
199199
activeTextEditor: undefined,
200200
onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
201-
createTextEditorDecorationType: vi.fn().mockReturnValue({}),
201+
createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }),
202202
tabGroups: {
203203
onDidChangeTabs: vi.fn().mockReturnValue({ dispose: vi.fn() }),
204204
},
@@ -691,13 +691,14 @@ describe("ClineProvider - Parallel Mode Support", () => {
691691
)
692692
const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext))
693693

694+
await (provider2 as any).saveViewState("mode", "debugger")
694695
await (provider1 as any).saveViewState("mode", "architect")
695696

696697
const state1 = await provider1.getState()
697698
const state2 = await provider2.getState()
698699

699700
expect(state1.mode).toBe("architect")
700-
expect(state2.mode).toBe("code")
701+
expect(state2.mode).toBe("debugger")
701702

702703
await provider1.dispose()
703704
await provider2.dispose()
@@ -1064,6 +1065,7 @@ describe("ClineProvider - Parallel Mode Support", () => {
10641065
expect(state.apiConfiguration.apiProvider).toBe("bedrock")
10651066
expect(state.apiConfiguration.awsBedrockEndpoint).toBe("http://127.0.0.1:4567")
10661067
expect((provider as any).viewLocalState.apiConfiguration.apiProvider).toBe("bedrock")
1068+
expect((provider as any).viewLocalState.apiConfiguration).not.toHaveProperty("openRouterModelId")
10671069

10681070
await provider.dispose()
10691071
})
@@ -1093,6 +1095,72 @@ describe("ClineProvider - Parallel Mode Support", () => {
10931095

10941096
await provider.dispose()
10951097
})
1098+
1099+
it("should sanitize raw viewStateId before using it as persisted viewStates key", async () => {
1100+
const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
1101+
1102+
await (provider as any).setViewStateId("tab panel/with.dots and spaces")
1103+
await provider.setValue("mode" as any, "architect" as any)
1104+
1105+
expect(provider.contextProxy.getValue("viewStates" as any)).toMatchObject({
1106+
tab_panel_with_dots_and_spaces: { mode: "architect" },
1107+
})
1108+
expect(provider.contextProxy.getValue("viewStates" as any)).not.toHaveProperty(
1109+
"tab panel/with.dots and spaces",
1110+
)
1111+
1112+
await provider.dispose()
1113+
})
1114+
1115+
it("should persist queued writes under the viewStateId active when the change was made", async () => {
1116+
let releaseFirstWrite!: () => void
1117+
const firstWriteStarted = new Promise<void>((resolve) => {
1118+
mockContext.globalState.update = vi
1119+
.fn()
1120+
.mockImplementationOnce((key: string, value: any) => {
1121+
mockContext.globalState.get = vi
1122+
.fn()
1123+
.mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined))
1124+
resolve()
1125+
return new Promise<void>((writeResolve) => {
1126+
releaseFirstWrite = writeResolve
1127+
})
1128+
})
1129+
.mockImplementation((key: string, value: any) => {
1130+
mockContext.globalState.get = vi
1131+
.fn()
1132+
.mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined))
1133+
return Promise.resolve()
1134+
})
1135+
})
1136+
const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
1137+
1138+
await (provider as any).setViewStateId("view-a")
1139+
const firstSave = (provider as any).saveViewState("mode", "architect")
1140+
await firstWriteStarted
1141+
await (provider as any).setViewStateId("view-b")
1142+
releaseFirstWrite()
1143+
await firstSave
1144+
1145+
expect(provider.contextProxy.getValue("viewStates" as any)).toMatchObject({
1146+
"view-a": { mode: "architect" },
1147+
})
1148+
expect(provider.contextProxy.getValue("viewStates" as any)).not.toHaveProperty("view-b")
1149+
1150+
await provider.dispose()
1151+
})
1152+
1153+
it("should clean up persisted viewStates entry when a tab provider is disposed", async () => {
1154+
const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext))
1155+
1156+
await (provider as any).setViewStateId("tab-to-dispose")
1157+
await (provider as any).saveViewState("mode", "architect")
1158+
expect(provider.contextProxy.getValue("viewStates" as any)).toHaveProperty("tab-to-dispose")
1159+
1160+
await provider.dispose()
1161+
1162+
expect(provider.contextProxy.getValue("viewStates" as any)).not.toHaveProperty("tab-to-dispose")
1163+
})
10961164
})
10971165

10981166
describe("profile mutations", () => {

webview-ui/src/utils/__tests__/vscode.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,10 @@ describe("VSCodeAPIWrapper", () => {
6161
})
6262

6363
it("falls back to in-memory state when browser storage access is restricted", () => {
64+
const randomUUID = vi.fn().mockReturnValueOnce("memory-view").mockReturnValueOnce("new-memory-view")
6465
Object.defineProperty(globalThis, "crypto", {
6566
configurable: true,
66-
value: { randomUUID: vi.fn(() => "memory-view") },
67+
value: { randomUUID },
6768
})
6869
const storage = {
6970
getItem: vi.fn(() => {
@@ -81,6 +82,7 @@ describe("VSCodeAPIWrapper", () => {
8182

8283
expect(wrapper.getViewStateId()).toBe("memory-view")
8384
expect(wrapper.getViewStateId()).toBe("memory-view")
85+
expect(randomUUID).toHaveBeenCalledTimes(1)
8486
expect(storage.getItem).toHaveBeenCalled()
8587
expect(storage.setItem).toHaveBeenCalled()
8688
})

0 commit comments

Comments
 (0)