Skip to content

Commit 77944d6

Browse files
committed
🤖 fix: prevent model selection race condition in creation mode
There were two issues causing the wrong model to be used when creating workspaces: 1. **Stale React state**: The `handleSend` callback in `useCreationWorkspace` captured `sendMessageOptions` from `useSendMessageOptions`, but that hook's state updates were delayed by `requestAnimationFrame` batching in `usePersistedState`. If the user selected a model and clicked send before the next animation frame, the old model value would be used. 2. **Effect overwriting selection**: The useEffect that initialized the model to the default when entering creation mode could re-run and overwrite the user's selection if `defaultModel` changed for any reason. **Fixes:** - Read send options fresh from localStorage at send time using `getSendOptionsFromStorage` instead of relying on potentially-stale React state - Track initialization state with a ref so the model is only set once per entry into creation mode, not on every `defaultModel` change Also added `mode` field to `getSendOptionsFromStorage` return value for parity with `useSendMessageOptions`. _Generated with `mux`_
1 parent 751dadd commit 77944d6

File tree

4 files changed

+31
-23
lines changed

4 files changed

+31
-23
lines changed

src/browser/components/ChatInput/index.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -253,12 +253,21 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
253253
}
254254
);
255255

256-
// When entering creation mode (or when the default model changes), reset the
257-
// project-scoped model to the explicit default so manual picks don't bleed
258-
// into subsequent creation flows.
256+
// When entering creation mode, initialize the project-scoped model to the
257+
// default so previous manual picks don't bleed into new creation flows.
258+
// Only runs once per creation session (not when defaultModel changes, which
259+
// would clobber the user's intentional model selection).
260+
const creationModelInitialized = useRef<string | null>(null);
259261
useEffect(() => {
260262
if (variant === "creation" && defaultModel) {
261-
updatePersistedState(storageKeys.modelKey, defaultModel);
263+
// Only initialize once per project scope
264+
if (creationModelInitialized.current !== storageKeys.modelKey) {
265+
creationModelInitialized.current = storageKeys.modelKey;
266+
updatePersistedState(storageKeys.modelKey, defaultModel);
267+
}
268+
} else if (variant !== "creation") {
269+
// Reset when leaving creation mode so re-entering triggers initialization
270+
creationModelInitialized.current = null;
262271
}
263272
}, [variant, defaultModel, storageKeys.modelKey]);
264273

src/browser/components/ChatInput/useCreationWorkspace.test.tsx

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import type { APIClient } from "@/browser/contexts/API";
22
import type { DraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
33
import {
44
getInputKey,
5+
getModelKey,
56
getModeKey,
67
getPendingScopeId,
78
getProjectScopeId,
89
getThinkingLevelKey,
910
} from "@/common/constants/storage";
1011
import type { SendMessageError } from "@/common/types/errors";
11-
import type { SendMessageOptions, WorkspaceChatMessage } from "@/common/orpc/types";
12+
import type { WorkspaceChatMessage } from "@/common/orpc/types";
1213
import type { RuntimeMode } from "@/common/types/runtime";
1314
import type {
1415
FrontendWorkspaceMetadata,
@@ -60,13 +61,6 @@ void mock.module("@/browser/hooks/useDraftWorkspaceSettings", () => ({
6061
useDraftWorkspaceSettings: useDraftWorkspaceSettingsMock,
6162
}));
6263

63-
let currentSendOptions: SendMessageOptions;
64-
const useSendMessageOptionsMock = mock(() => currentSendOptions);
65-
66-
void mock.module("@/browser/hooks/useSendMessageOptions", () => ({
67-
useSendMessageOptions: useSendMessageOptionsMock,
68-
}));
69-
7064
let currentORPCClient: MockOrpcClient | null = null;
7165
void mock.module("@/browser/contexts/API", () => ({
7266
useAPI: () => {
@@ -278,11 +272,6 @@ describe("useCreationWorkspace", () => {
278272
updatePersistedStateCalls.length = 0;
279273
draftSettingsInvocations = [];
280274
draftSettingsState = createDraftSettingsHarness();
281-
currentSendOptions = {
282-
model: "gpt-4",
283-
thinkingLevel: "medium",
284-
mode: "exec",
285-
} satisfies SendMessageOptions;
286275
});
287276

288277
afterEach(() => {
@@ -376,6 +365,8 @@ describe("useCreationWorkspace", () => {
376365

377366
persistedPreferences[getModeKey(getProjectScopeId(TEST_PROJECT_PATH))] = "plan";
378367
persistedPreferences[getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "high";
368+
// Set model preference for the project scope (read by getSendOptionsFromStorage)
369+
persistedPreferences[getModelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "gpt-4";
379370

380371
draftSettingsState = createDraftSettingsHarness({
381372
runtimeMode: "ssh",
@@ -412,8 +403,10 @@ describe("useCreationWorkspace", () => {
412403
expect(options?.projectPath).toBe(TEST_PROJECT_PATH);
413404
expect(options?.trunkBranch).toBe("dev");
414405
expect(options?.model).toBe("gpt-4");
415-
expect(options?.mode).toBe("exec");
416-
expect(options?.thinkingLevel).toBe("medium");
406+
// Mode was set to "plan" in persistedPreferences, so that's what we expect
407+
expect(options?.mode).toBe("plan");
408+
// thinkingLevel was set to "high" in persistedPreferences
409+
expect(options?.thinkingLevel).toBe("high");
417410
expect(options?.runtimeConfig).toEqual({
418411
type: "ssh",
419412
host: "example.com",

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { ThinkingLevel } from "@/common/types/thinking";
66
import { parseRuntimeString } from "@/browser/utils/chatCommands";
77
import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
88
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
9-
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
9+
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
1010
import {
1111
getInputKey,
1212
getModelKey,
@@ -95,8 +95,8 @@ export function useCreationWorkspace({
9595
getRuntimeString,
9696
} = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk);
9797

98-
// Get send options from shared hook (uses project-scoped storage key)
99-
const sendMessageOptions = useSendMessageOptions(getProjectScopeId(projectPath));
98+
// Project scope ID for reading send options at send time
99+
const projectScopeId = getProjectScopeId(projectPath);
100100

101101
// Load branches on mount
102102
useEffect(() => {
@@ -131,6 +131,11 @@ export function useCreationWorkspace({
131131
? parseRuntimeString(runtimeString, "")
132132
: undefined;
133133

134+
// Read send options fresh from localStorage at send time to avoid
135+
// race conditions with React state updates (requestAnimationFrame batching
136+
// in usePersistedState can delay state updates after model selection)
137+
const sendMessageOptions = getSendOptionsFromStorage(projectScopeId);
138+
134139
// Send message with runtime config and creation-specific params
135140
const result = await api.workspace.sendMessage({
136141
workspaceId: null,
@@ -188,9 +193,9 @@ export function useCreationWorkspace({
188193
api,
189194
isSending,
190195
projectPath,
196+
projectScopeId,
191197
onWorkspaceCreated,
192198
getRuntimeString,
193-
sendMessageOptions,
194199
settings.trunkBranch,
195200
]
196201
);

src/browser/utils/messages/sendOptions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio
6060

6161
return {
6262
model,
63+
mode: mode === "exec" || mode === "plan" ? mode : "exec", // Only pass exec/plan to backend
6364
thinkingLevel: effectiveThinkingLevel,
6465
toolPolicy: modeToToolPolicy(mode),
6566
additionalSystemInstructions,

0 commit comments

Comments
 (0)