Skip to content

Commit d78c608

Browse files
authored
🤖 fix: restore thinking level when model changes (#1136)
## Summary Fix model-switch thinking level restore and stop Review/diff “refresh” behavior on Cycle Model. ## Changes - Refactor `usePersistedState` to use React 18 `useSyncExternalStore` (stable localStorage-backed store) - Remove the keyed ThinkingProvider remount workaround (prevents subtree unmount/remount) - Add regression tests: model switch restores per-model thinking + does not remount children ## Risk Medium: `usePersistedState` is a shared primitive. Mitigated by stable snapshot caching and added tests. ## Testing - `make static-check` - `make typecheck` - `bun test src/browser` --- _Generated with `mux` • Model: `anthropic:claude-sonnet-4-20250514` • Thinking: `low`_
1 parent 1e69567 commit d78c608

File tree

3 files changed

+272
-144
lines changed

3 files changed

+272
-144
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { GlobalWindow } from "happy-dom";
2+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
3+
import { act, cleanup, render, waitFor } from "@testing-library/react";
4+
import React from "react";
5+
import { ThinkingProvider } from "./ThinkingContext";
6+
import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel";
7+
import { getModelKey, getThinkingLevelByModelKey } from "@/common/constants/storage";
8+
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
9+
10+
// Setup basic DOM environment for testing-library
11+
const dom = new GlobalWindow();
12+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
13+
(global as any).window = dom.window;
14+
(global as any).document = dom.window.document;
15+
16+
// Ensure globals exist for instanceof checks inside usePersistedState
17+
(globalThis as any).StorageEvent = dom.window.StorageEvent;
18+
(globalThis as any).CustomEvent = dom.window.CustomEvent;
19+
20+
(global as any).console = console;
21+
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
22+
23+
interface TestProps {
24+
workspaceId: string;
25+
}
26+
27+
const TestComponent: React.FC<TestProps> = (props) => {
28+
const [thinkingLevel] = useThinkingLevel();
29+
return (
30+
<div data-testid="thinking">
31+
{thinkingLevel}:{props.workspaceId}
32+
</div>
33+
);
34+
};
35+
36+
describe("ThinkingContext", () => {
37+
// Make getDefaultModel deterministic.
38+
// (getDefaultModel reads from the global "model-default" localStorage key.)
39+
beforeEach(() => {
40+
window.localStorage.clear();
41+
window.localStorage.setItem("model-default", JSON.stringify("openai:default"));
42+
});
43+
44+
afterEach(() => {
45+
cleanup();
46+
});
47+
48+
test("switching models does not remount children", async () => {
49+
const workspaceId = "ws-1";
50+
51+
updatePersistedState(getModelKey(workspaceId), "openai:gpt-5.2");
52+
updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "high");
53+
updatePersistedState(getThinkingLevelByModelKey("anthropic:claude-3.5"), "low");
54+
55+
let unmounts = 0;
56+
57+
const Child: React.FC = () => {
58+
React.useEffect(() => {
59+
return () => {
60+
unmounts += 1;
61+
};
62+
}, []);
63+
64+
const [thinkingLevel] = useThinkingLevel();
65+
return <div data-testid="child">{thinkingLevel}</div>;
66+
};
67+
68+
const view = render(
69+
<ThinkingProvider workspaceId={workspaceId}>
70+
<Child />
71+
</ThinkingProvider>
72+
);
73+
74+
await waitFor(() => {
75+
expect(view.getByTestId("child").textContent).toBe("high");
76+
});
77+
78+
act(() => {
79+
updatePersistedState(getModelKey(workspaceId), "anthropic:claude-3.5");
80+
});
81+
82+
await waitFor(() => {
83+
expect(view.getByTestId("child").textContent).toBe("low");
84+
});
85+
86+
expect(unmounts).toBe(0);
87+
});
88+
test("switching models restores the per-model thinking level", async () => {
89+
const workspaceId = "ws-1";
90+
91+
// Model A
92+
updatePersistedState(getModelKey(workspaceId), "openai:gpt-5.2");
93+
updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "high");
94+
95+
// Model B
96+
updatePersistedState(getThinkingLevelByModelKey("anthropic:claude-3.5"), "low");
97+
98+
const view = render(
99+
<ThinkingProvider workspaceId={workspaceId}>
100+
<TestComponent workspaceId={workspaceId} />
101+
</ThinkingProvider>
102+
);
103+
104+
await waitFor(() => {
105+
expect(view.getByTestId("thinking").textContent).toBe("high:ws-1");
106+
});
107+
108+
// Change model -> should restore that model's stored thinking level
109+
act(() => {
110+
updatePersistedState(getModelKey(workspaceId), "anthropic:claude-3.5");
111+
});
112+
113+
await waitFor(() => {
114+
expect(view.getByTestId("thinking").textContent).toBe("low:ws-1");
115+
});
116+
});
117+
});

src/browser/contexts/ThinkingContext.tsx

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ReactNode } from "react";
2-
import React, { createContext, useContext } from "react";
2+
import React, { createContext, useContext, useMemo } from "react";
33
import type { ThinkingLevel } from "@/common/types/thinking";
4-
import { usePersistedState, readPersistedState } from "@/browser/hooks/usePersistedState";
4+
import { usePersistedState } from "@/browser/hooks/usePersistedState";
55
import { getThinkingLevelByModelKey, getModelKey } from "@/common/constants/storage";
66
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
77
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";
@@ -20,46 +20,47 @@ interface ThinkingProviderProps {
2020
}
2121

2222
/**
23-
* Reads the current model from localStorage for the given scope.
24-
* Returns canonical model format (after gateway migration).
23+
* Hook to get the model key for the current scope.
2524
*/
26-
function getScopedModel(workspaceId?: string, projectPath?: string): string {
27-
const defaultModel = getDefaultModel();
28-
// Use workspace-scoped model key if available, otherwise project-scoped
29-
const modelKey = workspaceId
25+
function useModelKey(workspaceId?: string, projectPath?: string): string | null {
26+
return workspaceId
3027
? getModelKey(workspaceId)
3128
: projectPath
3229
? getModelKey(`__project__/${projectPath}`)
3330
: null;
34-
35-
if (!modelKey) {
36-
return defaultModel;
37-
}
38-
39-
const rawModel = readPersistedState<string>(modelKey, defaultModel);
40-
// Normalize to canonical format (e.g., strip legacy gateway prefix)
41-
return migrateGatewayModel(rawModel || defaultModel);
4231
}
4332

4433
export const ThinkingProvider: React.FC<ThinkingProviderProps> = ({
4534
workspaceId,
4635
projectPath,
4736
children,
4837
}) => {
49-
// Read current model from localStorage (non-reactive, re-reads on each render)
50-
const modelString = getScopedModel(workspaceId, projectPath);
51-
const key = getThinkingLevelByModelKey(modelString);
52-
const [thinkingLevel, setThinkingLevel] = usePersistedState<ThinkingLevel>(
53-
key,
54-
"off",
55-
{ listener: true } // Listen for changes from command palette and other sources
56-
);
38+
const defaultModel = getDefaultModel();
39+
const modelKey = useModelKey(workspaceId, projectPath);
40+
41+
// Subscribe to model changes so we update thinking level when model changes.
42+
// This uses a fallback key to satisfy hooks rules; it should be unused in practice
43+
// because ThinkingProvider is expected to have either workspaceId or projectPath.
44+
const [rawModel] = usePersistedState<string>(modelKey ?? "model:__unused__", defaultModel, {
45+
listener: true,
46+
});
47+
48+
const thinkingKey = useMemo(() => {
49+
const model = migrateGatewayModel(rawModel || defaultModel);
50+
return getThinkingLevelByModelKey(model);
51+
}, [rawModel, defaultModel]);
5752

58-
return (
59-
<ThinkingContext.Provider value={{ thinkingLevel, setThinkingLevel }}>
60-
{children}
61-
</ThinkingContext.Provider>
53+
const [thinkingLevel, setThinkingLevel] = usePersistedState<ThinkingLevel>(thinkingKey, "off", {
54+
listener: true,
55+
});
56+
57+
// Memoize context value to prevent unnecessary re-renders of consumers.
58+
const contextValue = useMemo(
59+
() => ({ thinkingLevel, setThinkingLevel }),
60+
[thinkingLevel, setThinkingLevel]
6261
);
62+
63+
return <ThinkingContext.Provider value={contextValue}>{children}</ThinkingContext.Provider>;
6364
};
6465

6566
export const useThinking = () => {

0 commit comments

Comments
 (0)