Skip to content

Commit 13fe3ad

Browse files
committed
fix(webview): sync cachedState on mode changes in SettingsView
1 parent 367013f commit 13fe3ad

2 files changed

Lines changed: 127 additions & 8 deletions

File tree

webview-ui/src/components/settings/SettingsView.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
130130
const { t } = useAppTranslation()
131131

132132
const extensionState = useExtensionState()
133-
const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt } = extensionState
133+
const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt, mode } = extensionState
134134

135135
const [isDiscardDialogShow, setDiscardDialogShow] = useState(false)
136136
const [isChangeDetected, setChangeDetected] = useState(false)
@@ -147,6 +147,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
147147
const contentRef = useRef<HTMLDivElement | null>(null)
148148

149149
const prevApiConfigName = useRef(currentApiConfigName)
150+
const prevMode = useRef(mode)
150151
const handledSettingsImportedAt = useRef<number | undefined>(undefined)
151152
const confirmDialogHandler = useRef<() => void>()
152153

@@ -220,16 +221,17 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
220221
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
221222

222223
useEffect(() => {
223-
// Update only when currentApiConfigName is changed.
224-
// Expected to be triggered by loadApiConfiguration/upsertApiConfiguration.
225-
if (prevApiConfigName.current === currentApiConfigName) {
224+
// Update when currentApiConfigName or mode changes.
225+
// Expected to be triggered by loadApiConfiguration/upsertApiConfiguration or mode switch.
226+
if (prevApiConfigName.current === currentApiConfigName && prevMode.current === mode) {
226227
return
227228
}
228229

229230
setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
230231
prevApiConfigName.current = currentApiConfigName
232+
prevMode.current = mode
231233
setChangeDetected(false)
232-
}, [currentApiConfigName, extensionState])
234+
}, [currentApiConfigName, mode, extensionState])
233235

234236
// Bust the cache when settings are imported.
235237
useEffect(() => {

webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,18 @@ vi.mock("@src/components/ui", () => ({
9494
TooltipProvider: ({ children }: any) => <>{children}</>,
9595
TooltipTrigger: ({ children }: any) => <>{children}</>,
9696
TooltipContent: ({ children }: any) => <div>{children}</div>,
97+
Command: ({ children }: any) => <div data-testid="command">{children}</div>,
98+
CommandInput: ({ value, onValueChange }: any) => (
99+
<input data-testid="command-input" value={value} onChange={(e) => onValueChange(e.target.value)} />
100+
),
101+
CommandGroup: ({ children }: any) => <div data-testid="command-group">{children}</div>,
102+
CommandItem: ({ children, onSelect }: any) => (
103+
<div data-testid="command-item" onClick={onSelect}>
104+
{children}
105+
</div>
106+
),
107+
CommandList: ({ children }: any) => <div data-testid="command-list">{children}</div>,
108+
CommandEmpty: ({ children }: any) => <div data-testid="command-empty">{children}</div>,
97109
Select: ({ children, value, onValueChange }: any) => (
98110
<div data-testid="select" data-value={value}>
99111
<button onClick={() => onValueChange && onValueChange("test-change")}>{value}</button>
@@ -332,6 +344,7 @@ describe("SettingsView - Change Detection Fix", () => {
332344
autoCloseZooOpenedFiles: true,
333345
autoCloseZooOpenedFilesAfterUserEdited: false,
334346
autoCloseZooOpenedNewFiles: false,
347+
mode: "code",
335348
...overrides,
336349
})
337350

@@ -373,7 +386,7 @@ describe("SettingsView - Change Detection Fix", () => {
373386

374387
// onDone should be called
375388
expect(onDone).toHaveBeenCalled()
376-
})
389+
}, 10000)
377390

378391
// These tests are passing for the basic case but failing due to vi.doMock limitations
379392
// The core fix has been verified - when no actual changes are made, no unsaved changes dialog appears
@@ -394,7 +407,7 @@ describe("SettingsView - Change Detection Fix", () => {
394407
// - null -> value (initialization from null)
395408

396409
expect(true).toBe(true) // Placeholder - the real test is the running system
397-
})
410+
}, 10000)
398411

399412
it("preserves a DeepSeek provider edit after saving Baseten when the same import timestamp replays", async () => {
400413
const onDone = vi.fn()
@@ -473,7 +486,7 @@ describe("SettingsView - Change Detection Fix", () => {
473486
apiProvider: "deepseek",
474487
}),
475488
})
476-
})
489+
}, 10000)
477490

478491
it("resets cached provider state when a new import timestamp arrives", async () => {
479492
const onDone = vi.fn()
@@ -525,5 +538,109 @@ describe("SettingsView - Change Detection Fix", () => {
525538
await waitFor(() => {
526539
expect(screen.getByTestId("save-button")).toBeDisabled()
527540
})
541+
}, 10000)
542+
543+
describe("mode synchronization", () => {
544+
it("resets changeDetected and syncs cachedState when mode changes after dirty state", async () => {
545+
const onDone = vi.fn()
546+
let extensionState = createExtensionState({
547+
mode: "code",
548+
apiConfiguration: {
549+
apiProvider: "openai",
550+
apiModelId: "gpt-4.1",
551+
},
552+
})
553+
554+
;(useExtensionState as any).mockImplementation(() => extensionState)
555+
556+
const { rerender } = render(
557+
<QueryClientProvider client={queryClient}>
558+
<SettingsView onDone={onDone} />
559+
</QueryClientProvider>,
560+
)
561+
562+
await waitFor(() => {
563+
expect(screen.getByTestId("provider-value")).toHaveTextContent("openai")
564+
})
565+
566+
// Make a dirty change by switching provider
567+
fireEvent.click(screen.getByTestId("set-provider-baseten"))
568+
expect(screen.getByTestId("provider-value")).toHaveTextContent("baseten")
569+
570+
// Verify save button is enabled (dirty state)
571+
const saveButton = screen.getByTestId("save-button") as HTMLButtonElement
572+
expect(saveButton.disabled).toBe(false)
573+
574+
// Now change the mode - this should trigger the mode sync effect
575+
await act(async () => {
576+
extensionState = createExtensionState({
577+
mode: "ask",
578+
apiConfiguration: {
579+
apiProvider: "openrouter",
580+
apiModelId: "claude-3.5-sonnet",
581+
},
582+
})
583+
;(useExtensionState as any).mockImplementation(() => extensionState)
584+
585+
rerender(
586+
<QueryClientProvider client={queryClient}>
587+
<SettingsView onDone={onDone} />
588+
</QueryClientProvider>,
589+
)
590+
})
591+
592+
// Let the mode sync effect run
593+
await act(async () => {
594+
await new Promise((resolve) => setTimeout(resolve, 0))
595+
})
596+
597+
// Verify cachedState reflects the new mode's settings
598+
await waitFor(() => {
599+
expect(screen.getByTestId("provider-value")).toHaveTextContent("openrouter")
600+
})
601+
602+
// Verify changeDetected is reset (save button should be disabled)
603+
const updatedSaveButton = screen.getByTestId("save-button") as HTMLButtonElement
604+
expect(updatedSaveButton.disabled).toBe(true)
605+
}, 20000)
606+
607+
it("does not trigger sync when mode has not changed", async () => {
608+
const onDone = vi.fn()
609+
const extensionState = createExtensionState({
610+
mode: "code",
611+
apiConfiguration: {
612+
apiProvider: "openai",
613+
apiModelId: "gpt-4.1",
614+
},
615+
})
616+
617+
;(useExtensionState as any).mockImplementation(() => extensionState)
618+
619+
const { rerender } = render(
620+
<QueryClientProvider client={queryClient}>
621+
<SettingsView onDone={onDone} />
622+
</QueryClientProvider>,
623+
)
624+
625+
await waitFor(() => {
626+
expect(screen.getByTestId("provider-value")).toHaveTextContent("openai")
627+
})
628+
629+
// Make a dirty change so we can verify it isn't overwritten by a sync
630+
fireEvent.click(screen.getByTestId("set-provider-baseten"))
631+
expect(screen.getByTestId("provider-value")).toHaveTextContent("baseten")
632+
633+
// Re-render with same mode - should not trigger sync
634+
await act(async () => {
635+
rerender(
636+
<QueryClientProvider client={queryClient}>
637+
<SettingsView onDone={onDone} />
638+
</QueryClientProvider>,
639+
)
640+
})
641+
642+
// Provider value should remain unchanged from the dirty state
643+
expect(screen.getByTestId("provider-value")).toHaveTextContent("baseten")
644+
}, 20000)
528645
})
529646
})

0 commit comments

Comments
 (0)