Skip to content

Commit 728f21e

Browse files
committed
feat(webview): isolate parallel mode and profile writes
1 parent ba853ed commit 728f21e

3 files changed

Lines changed: 153 additions & 4 deletions

File tree

src/core/webview/ClineProvider.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1674,8 +1674,8 @@ export class ClineProvider
16741674
// The task will continue with the current/default configuration.
16751675
}
16761676
} else {
1677-
// If no saved config for this mode, save current config as default.
1678-
const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName")
1677+
// If no saved config for this mode, save this view's current config as default.
1678+
const { currentApiConfigName: currentApiConfigNameAfter } = await this.getState()
16791679

16801680
if (currentApiConfigNameAfter) {
16811681
const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter)
@@ -1772,6 +1772,8 @@ export class ClineProvider
17721772
this.updateGlobalState("currentApiConfigName", name),
17731773
this.providerSettingsManager.setModeConfig(mode, id),
17741774
this.contextProxy.setProviderSettings(providerSettings),
1775+
this.saveViewState("currentApiConfigName", name),
1776+
this.saveViewState("apiConfiguration", providerSettings),
17751777
])
17761778

17771779
// Change the provider for the current task.
@@ -1797,8 +1799,10 @@ export class ClineProvider
17971799
}
17981800

17991801
async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) {
1800-
const globalSettings = this.contextProxy.getValues()
1801-
let profileToActivate: string | undefined = globalSettings.currentApiConfigName
1802+
// Use merged state (view-local + global) so this tab reads its own current profile,
1803+
// not another parallel tab's latest shared ContextProxy value.
1804+
const { currentApiConfigName } = await this.getState()
1805+
let profileToActivate: string | undefined = currentApiConfigName
18021806

18031807
if (profileToDelete.name === profileToActivate) {
18041808
profileToActivate = this.getProviderProfileEntries().find(({ name }) => name !== profileToDelete.name)?.name
@@ -1809,12 +1813,14 @@ export class ClineProvider
18091813
}
18101814

18111815
const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name)
1816+
const globalSettings = await this.getState()
18121817

18131818
await this.contextProxy.setValues({
18141819
...globalSettings,
18151820
currentApiConfigName: profileToActivate,
18161821
listApiConfigMeta: entries,
18171822
})
1823+
await this.saveViewState("currentApiConfigName", profileToActivate)
18181824

18191825
await this.postStateToWebview()
18201826
}
@@ -1861,6 +1867,8 @@ export class ClineProvider
18611867
this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
18621868
this.contextProxy.setValue("currentApiConfigName", name),
18631869
this.contextProxy.setProviderSettings(providerSettings),
1870+
this.saveViewState("currentApiConfigName", name),
1871+
this.saveViewState("apiConfiguration", providerSettings),
18641872
])
18651873

18661874
const { mode } = await this.getState()

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,132 @@ describe("ClineProvider - Parallel Mode Support", () => {
10221022
})
10231023
})
10241024

1025+
describe("parallel mode writes", () => {
1026+
it("should persist mode switches to the stable view-local state key", async () => {
1027+
const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
1028+
await (provider as any).setViewStateId("stable-sidebar-mode")
1029+
const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue")
1030+
1031+
await provider.handleModeSwitch("architect")
1032+
1033+
expect(contextProxySpy).toHaveBeenCalledWith("__view_state_stable-sidebar-mode_mode", "architect")
1034+
const state = await provider.getState()
1035+
expect(state.mode).toBe("architect")
1036+
1037+
await provider.dispose()
1038+
})
1039+
1040+
it("should keep mode switches isolated between parallel provider instances", async () => {
1041+
const provider1 = new ClineProvider(
1042+
mockContext,
1043+
mockOutputChannel,
1044+
"sidebar",
1045+
new ContextProxy(mockContext),
1046+
)
1047+
const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext))
1048+
await (provider1 as any).setViewStateId("stable-sidebar-mode-a")
1049+
await (provider2 as any).setViewStateId("stable-editor-mode-b")
1050+
1051+
await provider1.handleModeSwitch("architect")
1052+
await provider2.handleModeSwitch("debugger" as any)
1053+
1054+
const state1 = await provider1.getState()
1055+
const state2 = await provider2.getState()
1056+
expect(state1.mode).toBe("architect")
1057+
expect(state2.mode).toBe("debugger")
1058+
expect(provider1.contextProxy.getValue("__view_state_stable-sidebar-mode-a_mode" as any)).toBe("architect")
1059+
expect(provider2.contextProxy.getValue("__view_state_stable-editor-mode-b_mode" as any)).toBe("debugger")
1060+
1061+
await provider1.dispose()
1062+
await provider2.dispose()
1063+
})
1064+
1065+
it("should update view-local profile state when activating a provider profile", async () => {
1066+
const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
1067+
await (provider as any).setViewStateId("stable-sidebar-profile")
1068+
const providerSettings = {
1069+
apiProvider: "openrouter" as const,
1070+
openRouterModelId: "openrouter/anthropic/claude-sonnet-4",
1071+
}
1072+
vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
1073+
name: "profile-a",
1074+
id: "profile-a-id",
1075+
...providerSettings,
1076+
} as any)
1077+
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
1078+
{ name: "profile-a", id: "profile-a-id", apiProvider: "openrouter" },
1079+
])
1080+
1081+
await provider.activateProviderProfile({ name: "profile-a" })
1082+
1083+
const state = await provider.getState()
1084+
expect(state.currentApiConfigName).toBe("profile-a")
1085+
expect(state.apiConfiguration).toMatchObject(providerSettings)
1086+
expect(
1087+
provider.contextProxy.getValue("__view_state_stable-sidebar-profile_currentApiConfigName" as any),
1088+
).toBe("profile-a")
1089+
expect(
1090+
provider.contextProxy.getValue("__view_state_stable-sidebar-profile_apiConfiguration" as any),
1091+
).toMatchObject(providerSettings)
1092+
1093+
await provider.dispose()
1094+
})
1095+
1096+
it("should update view-local profile state when upserting and activating a provider profile", async () => {
1097+
const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
1098+
await (provider as any).setViewStateId("stable-sidebar-upsert")
1099+
const providerSettings = {
1100+
apiProvider: "openrouter" as const,
1101+
openRouterModelId: "openrouter/anthropic/claude-sonnet-4",
1102+
}
1103+
vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("profile-b-id")
1104+
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
1105+
{ name: "profile-b", id: "profile-b-id", apiProvider: "openrouter" },
1106+
])
1107+
1108+
await provider.upsertProviderProfile("profile-b", providerSettings, true)
1109+
1110+
const state = await provider.getState()
1111+
expect(state.currentApiConfigName).toBe("profile-b")
1112+
expect(state.apiConfiguration).toMatchObject(providerSettings)
1113+
expect(
1114+
provider.contextProxy.getValue("__view_state_stable-sidebar-upsert_currentApiConfigName" as any),
1115+
).toBe("profile-b")
1116+
expect(
1117+
provider.contextProxy.getValue("__view_state_stable-sidebar-upsert_apiConfiguration" as any),
1118+
).toMatchObject(providerSettings)
1119+
1120+
await provider.dispose()
1121+
})
1122+
})
1123+
1124+
describe("deleteProviderProfile", () => {
1125+
it("should use merged view-local state when choosing the profile to keep", async () => {
1126+
const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
1127+
await (provider as any).setViewStateId("stable-delete-profile")
1128+
await (provider as any).saveViewState("currentApiConfigName", "profile-b")
1129+
1130+
await provider.contextProxy.setValues({
1131+
currentApiConfigName: "profile-a",
1132+
listApiConfigMeta: [
1133+
{ id: "a-id", name: "profile-a", apiProvider: "anthropic" },
1134+
{ id: "b-id", name: "profile-b", apiProvider: "openrouter" },
1135+
{ id: "del-id", name: "profile-to-delete", apiProvider: "anthropic" },
1136+
],
1137+
})
1138+
1139+
await provider.deleteProviderProfile({ id: "del-id", name: "profile-to-delete", apiProvider: "anthropic" })
1140+
1141+
const state = await provider.getState()
1142+
expect(state.currentApiConfigName).toBe("profile-b")
1143+
expect(
1144+
provider.contextProxy.getValue("__view_state_stable-delete-profile_currentApiConfigName" as any),
1145+
).toBe("profile-b")
1146+
1147+
await provider.dispose()
1148+
})
1149+
})
1150+
10251151
describe("_clearViewLocalState", () => {
10261152
it("should clear all view-local state values", async () => {
10271153
const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,21 @@ describe("webviewMessageHandler - webviewDidLaunch", () => {
239239
})
240240
})
241241

242+
describe("webviewMessageHandler - mode", () => {
243+
beforeEach(() => {
244+
vi.clearAllMocks()
245+
vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined)
246+
;(mockClineProvider as any).handleModeSwitch = vi.fn().mockResolvedValue(undefined)
247+
})
248+
249+
it("routes mode messages through handleModeSwitch instead of writing ContextProxy directly", async () => {
250+
await webviewMessageHandler(mockClineProvider, { type: "mode", text: "architect" })
251+
252+
expect((mockClineProvider as any).handleModeSwitch).toHaveBeenCalledWith("architect")
253+
expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("mode", expect.anything())
254+
})
255+
})
256+
242257
describe("webviewMessageHandler - requestLmStudioModels", () => {
243258
beforeEach(() => {
244259
vi.clearAllMocks()

0 commit comments

Comments
 (0)