Skip to content

Commit f2338c4

Browse files
committed
fix(webview): preserve isolated view state writes
1 parent 37a5dd1 commit f2338c4

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
@@ -451,9 +451,10 @@ export class ClineProvider
451451
}
452452

453453
private async savePersistedViewState(values: Partial<PersistedViewState>): Promise<void> {
454+
const viewStateId = this.viewStateId
454455
const write = ClineProvider.persistedViewStateWriteQueue.then(async () => {
455456
const states = this.getPersistedViewStates({ fresh: true })
456-
const current = states[this.viewStateId] ?? {}
457+
const current = states[viewStateId] ?? {}
457458
const next: PersistedViewState = { ...current }
458459

459460
if ("mode" in values) {
@@ -473,10 +474,10 @@ export class ClineProvider
473474
}
474475

475476
if (!next.mode && !next.currentApiConfigName) {
476-
delete states[this.viewStateId]
477+
delete states[viewStateId]
477478
} else {
478479
next.updatedAt = values.updatedAt ?? Date.now()
479-
states[this.viewStateId] = next
480+
states[viewStateId] = next
480481
}
481482

482483
await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states))
@@ -557,18 +558,18 @@ export class ClineProvider
557558
* Save a single view-local state value. Only non-secret selections are persisted durably.
558559
*/
559560
private async saveViewState(key: keyof ExtensionState, value: any): Promise<void> {
560-
if (value === undefined || value === null) {
561-
delete this.viewLocalState[key]
562-
} else {
563-
this.viewLocalState[key] = value
564-
}
565-
566561
if (key === "mode") {
567562
await this.savePersistedViewState({ mode: value })
568563
} else if (key === "currentApiConfigName") {
569564
await this.savePersistedViewState({ currentApiConfigName: value })
570565
}
571566

567+
if (value === undefined || value === null) {
568+
delete this.viewLocalState[key]
569+
} else {
570+
this.viewLocalState[key] = value
571+
}
572+
572573
this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`)
573574
}
574575

@@ -894,6 +895,15 @@ export class ClineProvider
894895
this.customModesManager?.dispose()
895896
this.taskHistoryStore.dispose()
896897
this.flushGlobalStateWriteThrough()
898+
if (this.renderContext === "editor") {
899+
try {
900+
await this.clearPersistedViewState()
901+
} catch (error) {
902+
this.log(
903+
`[dispose] Failed to clear persisted view state for ${this.viewStateId}: ${error instanceof Error ? error.message : String(error)}`,
904+
)
905+
}
906+
}
897907
this.log("Disposed all disposables")
898908
ClineProvider.activeInstances.delete(this)
899909

@@ -3130,10 +3140,13 @@ export class ClineProvider
31303140
return acc
31313141
}, {} as ProviderSettings)
31323142

3133-
this.viewLocalState.apiConfiguration = {
3134-
...(this.viewLocalState.apiConfiguration ?? {}),
3135-
...providerSettingsUpdate,
3136-
}
3143+
this.viewLocalState.apiConfiguration =
3144+
"apiProvider" in providerSettingsUpdate
3145+
? providerSettingsUpdate
3146+
: {
3147+
...(this.viewLocalState.apiConfiguration ?? {}),
3148+
...providerSettingsUpdate,
3149+
}
31373150
}
31383151
}
31393152

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)