Skip to content

Commit e77ca7e

Browse files
committed
fix(webview): persist view state by stable id
1 parent 1922745 commit e77ca7e

8 files changed

Lines changed: 99 additions & 12 deletions

File tree

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,7 @@ export interface WebviewMessage {
621621
| "openRuleFile"
622622
| "openRulesDirectory"
623623
text?: string
624+
viewStateId?: string
624625
taskId?: string
625626
editedMessageContent?: string
626627
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"

src/core/webview/ClineProvider.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,12 @@ export class ClineProvider
219219
*/
220220
public readonly viewId: string
221221

222+
/**
223+
* Stable identifier for persisted per-view state keys.
224+
* Defaults to viewId until the webview reports its VS Code-persisted id.
225+
*/
226+
private viewStateId: string
227+
222228
/**
223229
* Local state buffer for this specific view instance.
224230
* Used to isolate mode, apiConfiguration, and other fields from the shared ContextProxy singleton
@@ -243,6 +249,7 @@ export class ClineProvider
243249
// Initialize viewId based on renderContext and monotonically increasing instance identifier for uniqueness.
244250
// activeInstances is used for visibility/iteration checks, so we keep tracking instances separately.
245251
this.viewId = `${renderContext}-${ClineProvider.nextViewId++}`
252+
this.viewStateId = this.viewId
246253
ClineProvider.activeInstances.add(this)
247254
this.currentWorkspacePath = getWorkspacePath()
248255
this.pendingEditOperations = new PendingEditOperationStore(
@@ -423,10 +430,22 @@ export class ClineProvider
423430

424431
/**
425432
* Derive a view-specific ContextProxy key for persisting view-local state.
426-
* Uses the current viewId so each parallel tab restores its own values on recreation.
433+
* Uses a stable per-view id so each restored tab reads and writes its own values
434+
* independent of provider construction order.
427435
*/
428436
private viewStateKeyFor(key: "mode" | "currentApiConfigName" | "apiConfiguration"): string {
429-
return `__view_state_${this.viewId}_${key}`
437+
return `__view_state_${this.viewStateId}_${key}`
438+
}
439+
440+
public async setViewStateId(viewStateId: string | undefined): Promise<void> {
441+
const normalizedViewStateId = viewStateId?.trim()
442+
443+
if (!normalizedViewStateId || normalizedViewStateId === this.viewStateId) {
444+
return
445+
}
446+
447+
this.viewStateId = normalizedViewStateId.replace(/[^A-Za-z0-9_-]/g, "_")
448+
await this.loadViewState()
430449
}
431450

432451
/**
@@ -440,15 +459,18 @@ export class ClineProvider
440459
const providerSettings = this.contextProxy.getProviderSettings()
441460

442461
// Try view-specific keys first, then fall back to shared keys for backward compatibility.
443-
const getViewSpecificValue = (sharedKey: "mode" | "currentApiConfigName") => {
462+
const getViewSpecificValue = (sharedKey: "mode" | "currentApiConfigName" | "apiConfiguration") => {
444463
const viewKey = this.viewStateKeyFor(sharedKey)
445-
return (this.contextProxy.getValue(viewKey as any) as any) ?? stateValues[sharedKey]
464+
return (
465+
(this.contextProxy.getValue(viewKey as any) as any) ??
466+
(sharedKey === "apiConfiguration" ? providerSettings : stateValues[sharedKey])
467+
)
446468
}
447469

448470
this.viewLocalState = {
449471
mode: getViewSpecificValue("mode"),
450472
currentApiConfigName: getViewSpecificValue("currentApiConfigName"),
451-
apiConfiguration: providerSettings,
473+
apiConfiguration: getViewSpecificValue("apiConfiguration") ?? providerSettings,
452474
customModePrompts: stateValues.customModePrompts,
453475
modeApiConfigs: stateValues.modeApiConfigs,
454476
}

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

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -741,15 +741,16 @@ describe("ClineProvider - Parallel Mode Support", () => {
741741
const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
742742

743743
const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue")
744+
await (provider as any).setViewStateId("stable-sidebar-view")
744745

745746
await (provider as any).saveViewState("mode", "architect")
746747

747748
// Verify viewLocalState was updated
748749
expect((provider as any).viewLocalState.mode).toBe("architect")
749750

750-
// saveViewState now uses view-specific key for mode/currentApiConfigName/apiConfiguration
751-
const expectedViewKey = `__view_state_sidebar-${provider.viewId.split("-")[1]}_mode`
752-
expect(contextProxySpy).toHaveBeenCalledWith(expectedViewKey, "architect")
751+
// saveViewState uses the stable per-view id, not the construction-order viewId suffix.
752+
expect(contextProxySpy).toHaveBeenCalledWith("__view_state_stable-sidebar-view_mode", "architect")
753+
expect(contextProxySpy).not.toHaveBeenCalledWith(`__view_state_${provider.viewId}_mode`, expect.anything())
753754

754755
await provider.dispose()
755756
})
@@ -859,6 +860,37 @@ describe("ClineProvider - Parallel Mode Support", () => {
859860
await provider.dispose()
860861
})
861862

863+
it("should restore mode, current API config name, and API configuration from stable per-view state", async () => {
864+
const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext))
865+
const stableViewId = "stable-editor-tab-a"
866+
const persistedApiConfiguration = {
867+
apiProvider: "openrouter" as const,
868+
openRouterModelId: "openrouter/anthropic/claude-sonnet-4",
869+
}
870+
871+
await provider.contextProxy.setValue(`__view_state_${stableViewId}_mode` as any, "architect")
872+
await provider.contextProxy.setValue(
873+
`__view_state_${stableViewId}_currentApiConfigName` as any,
874+
"profile-a",
875+
)
876+
await provider.contextProxy.setValue(
877+
`__view_state_${stableViewId}_apiConfiguration` as any,
878+
persistedApiConfiguration,
879+
)
880+
await provider.contextProxy.setValue("mode" as any, "debugger")
881+
await provider.contextProxy.setValue("currentApiConfigName" as any, "profile-b")
882+
await provider.contextProxy.setValue("apiConfiguration" as any, { apiProvider: "anthropic" })
883+
884+
await (provider as any).setViewStateId(stableViewId)
885+
const state = await provider.getState()
886+
887+
expect(state.mode).toBe("architect")
888+
expect(state.currentApiConfigName).toBe("profile-a")
889+
expect(state.apiConfiguration).toMatchObject(persistedApiConfiguration)
890+
891+
await provider.dispose()
892+
})
893+
862894
it("should log and keep existing viewLocalState when loadViewState fails", async () => {
863895
const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
864896
const logSpy = vi.spyOn(provider as any, "log")

src/core/webview/webviewMessageHandler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,8 @@ export const webviewMessageHandler = async (
558558

559559
switch (message.type) {
560560
case "webviewDidLaunch":
561+
await provider.setViewStateId(message.viewStateId)
562+
561563
// Load custom modes first
562564
const customModes = await provider.customModesManager.getCustomModes()
563565
await updateGlobalState("customModes", customModes)

webview-ui/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ const App = () => {
191191
}, [telemetrySetting, telemetryKey, machineId, didHydrateState])
192192

193193
// Tell the extension that we are ready to receive messages.
194-
useEffect(() => vscode.postMessage({ type: "webviewDidLaunch" }), [])
194+
useEffect(() => vscode.postMessage({ type: "webviewDidLaunch", viewStateId: vscode.getViewStateId() }), [])
195195

196196
// Initialize source map support for better error reporting
197197
useEffect(() => {

webview-ui/src/__tests__/App.spec.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import AppWithProviders from "../App"
88
vi.mock("@src/utils/vscode", () => ({
99
vscode: {
1010
postMessage: vi.fn(),
11+
getViewStateId: vi.fn(() => "test-view-state-id"),
1112
},
1213
}))
1314

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
489489
}, [handleMessage])
490490

491491
useEffect(() => {
492-
vscode.postMessage({ type: "webviewDidLaunch" })
492+
vscode.postMessage({ type: "webviewDidLaunch", viewStateId: vscode.getViewStateId() })
493493
}, [])
494494

495495
// Apply the configurable chat font size as a CSS variable. When unset, the

webview-ui/src/utils/vscode.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,31 @@ class VSCodeAPIWrapper {
2222
}
2323
}
2424

25+
private createViewStateId(): string {
26+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
27+
return crypto.randomUUID()
28+
}
29+
30+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
31+
}
32+
33+
public getViewStateId(): string {
34+
const currentState = this.getState()
35+
const stateObject =
36+
currentState && typeof currentState === "object" && !Array.isArray(currentState)
37+
? (currentState as Record<string, unknown>)
38+
: {}
39+
const existingViewStateId = stateObject.viewStateId
40+
41+
if (typeof existingViewStateId === "string" && existingViewStateId.length > 0) {
42+
return existingViewStateId
43+
}
44+
45+
const viewStateId = this.createViewStateId()
46+
this.setState({ ...stateObject, viewStateId })
47+
return viewStateId
48+
}
49+
2550
/**
2651
* Post a message (i.e. send arbitrary data) to the owner of the webview.
2752
*
@@ -49,9 +74,11 @@ class VSCodeAPIWrapper {
4974
public getState(): unknown | undefined {
5075
if (this.vsCodeApi) {
5176
return this.vsCodeApi.getState()
52-
} else {
77+
} else if (typeof localStorage?.getItem === "function") {
5378
const state = localStorage.getItem("vscodeState")
5479
return state ? JSON.parse(state) : undefined
80+
} else {
81+
return undefined
5582
}
5683
}
5784

@@ -70,7 +97,9 @@ class VSCodeAPIWrapper {
7097
if (this.vsCodeApi) {
7198
return this.vsCodeApi.setState(newState)
7299
} else {
73-
localStorage.setItem("vscodeState", JSON.stringify(newState))
100+
if (typeof localStorage?.setItem === "function") {
101+
localStorage.setItem("vscodeState", JSON.stringify(newState))
102+
}
74103
return newState
75104
}
76105
}

0 commit comments

Comments
 (0)