diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 4687e2b587..02abe3e7ac 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -253,12 +253,21 @@ export const ChatInput: React.FC = (props) => { } ); - // When entering creation mode (or when the default model changes), reset the - // project-scoped model to the explicit default so manual picks don't bleed - // into subsequent creation flows. + // When entering creation mode, initialize the project-scoped model to the + // default so previous manual picks don't bleed into new creation flows. + // Only runs once per creation session (not when defaultModel changes, which + // would clobber the user's intentional model selection). + const creationModelInitialized = useRef(null); useEffect(() => { if (variant === "creation" && defaultModel) { - updatePersistedState(storageKeys.modelKey, defaultModel); + // Only initialize once per project scope + if (creationModelInitialized.current !== storageKeys.modelKey) { + creationModelInitialized.current = storageKeys.modelKey; + updatePersistedState(storageKeys.modelKey, defaultModel); + } + } else if (variant !== "creation") { + // Reset when leaving creation mode so re-entering triggers initialization + creationModelInitialized.current = null; } }, [variant, defaultModel, storageKeys.modelKey]); diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index 172f45f5fc..6fca864302 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -2,13 +2,14 @@ import type { APIClient } from "@/browser/contexts/API"; import type { DraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings"; import { getInputKey, + getModelKey, getModeKey, getPendingScopeId, getProjectScopeId, getThinkingLevelKey, } from "@/common/constants/storage"; import type { SendMessageError } from "@/common/types/errors"; -import type { SendMessageOptions, WorkspaceChatMessage } from "@/common/orpc/types"; +import type { WorkspaceChatMessage } from "@/common/orpc/types"; import type { RuntimeMode } from "@/common/types/runtime"; import type { FrontendWorkspaceMetadata, @@ -60,13 +61,6 @@ void mock.module("@/browser/hooks/useDraftWorkspaceSettings", () => ({ useDraftWorkspaceSettings: useDraftWorkspaceSettingsMock, })); -let currentSendOptions: SendMessageOptions; -const useSendMessageOptionsMock = mock(() => currentSendOptions); - -void mock.module("@/browser/hooks/useSendMessageOptions", () => ({ - useSendMessageOptions: useSendMessageOptionsMock, -})); - let currentORPCClient: MockOrpcClient | null = null; void mock.module("@/browser/contexts/API", () => ({ useAPI: () => { @@ -278,11 +272,6 @@ describe("useCreationWorkspace", () => { updatePersistedStateCalls.length = 0; draftSettingsInvocations = []; draftSettingsState = createDraftSettingsHarness(); - currentSendOptions = { - model: "gpt-4", - thinkingLevel: "medium", - mode: "exec", - } satisfies SendMessageOptions; }); afterEach(() => { @@ -376,6 +365,8 @@ describe("useCreationWorkspace", () => { persistedPreferences[getModeKey(getProjectScopeId(TEST_PROJECT_PATH))] = "plan"; persistedPreferences[getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "high"; + // Set model preference for the project scope (read by getSendOptionsFromStorage) + persistedPreferences[getModelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "gpt-4"; draftSettingsState = createDraftSettingsHarness({ runtimeMode: "ssh", @@ -412,8 +403,10 @@ describe("useCreationWorkspace", () => { expect(options?.projectPath).toBe(TEST_PROJECT_PATH); expect(options?.trunkBranch).toBe("dev"); expect(options?.model).toBe("gpt-4"); - expect(options?.mode).toBe("exec"); - expect(options?.thinkingLevel).toBe("medium"); + // Mode was set to "plan" in persistedPreferences, so that's what we expect + expect(options?.mode).toBe("plan"); + // thinkingLevel was set to "high" in persistedPreferences + expect(options?.thinkingLevel).toBe("high"); expect(options?.runtimeConfig).toEqual({ type: "ssh", host: "example.com", diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index ecd41ea9e0..513dbff0f9 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -6,7 +6,7 @@ import type { ThinkingLevel } from "@/common/types/thinking"; import { parseRuntimeString } from "@/browser/utils/chatCommands"; import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; +import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { getInputKey, getModelKey, @@ -95,8 +95,8 @@ export function useCreationWorkspace({ getRuntimeString, } = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); - // Get send options from shared hook (uses project-scoped storage key) - const sendMessageOptions = useSendMessageOptions(getProjectScopeId(projectPath)); + // Project scope ID for reading send options at send time + const projectScopeId = getProjectScopeId(projectPath); // Load branches on mount useEffect(() => { @@ -131,6 +131,11 @@ export function useCreationWorkspace({ ? parseRuntimeString(runtimeString, "") : undefined; + // Read send options fresh from localStorage at send time to avoid + // race conditions with React state updates (requestAnimationFrame batching + // in usePersistedState can delay state updates after model selection) + const sendMessageOptions = getSendOptionsFromStorage(projectScopeId); + // Send message with runtime config and creation-specific params const result = await api.workspace.sendMessage({ workspaceId: null, @@ -188,9 +193,9 @@ export function useCreationWorkspace({ api, isSending, projectPath, + projectScopeId, onWorkspaceCreated, getRuntimeString, - sendMessageOptions, settings.trunkBranch, ] ); diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index 7de1fbbe97..e8bfb07c7b 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -60,6 +60,7 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio return { model, + mode: mode === "exec" || mode === "plan" ? mode : "exec", // Only pass exec/plan to backend thinkingLevel: effectiveThinkingLevel, toolPolicy: modeToToolPolicy(mode), additionalSystemInstructions,