diff --git a/src/client/components/chat-ui/ChatInput.tsx b/src/client/components/chat-ui/ChatInput.tsx index dcd732008..bde714d5d 100644 --- a/src/client/components/chat-ui/ChatInput.tsx +++ b/src/client/components/chat-ui/ChatInput.tsx @@ -21,7 +21,7 @@ import { useVoiceRecorder } from "../../hooks/useVoiceRecorder" import { RecordingWaveform } from "./RecordingWaveform" import { useShallow } from "zustand/react/shallow" import { useChatInputStore } from "../../stores/chatInputStore" -import { type ComposerState, useChatPreferencesStore } from "../../stores/chatPreferencesStore" +import { NEW_CHAT_COMPOSER_ID, type ComposerState, useChatPreferencesStore } from "../../stores/chatPreferencesStore" import { CHAT_INPUT_ATTRIBUTE, focusNextChatInput, REQUEST_ATTACH_FILES_EVENT } from "../../app/chatFocusPolicy" import { abbreviatePathHead, formatPathWithTilde } from "../../lib/pathUtils" import { copyTextToClipboard } from "../../lib/clipboard" @@ -474,8 +474,13 @@ const ChatInputInner = forwardRef(function ChatInput({ setValue((current) => (current === "" ? current : "")) }, [chatId, storedDraft]) + // Only the new-chat composer gets an eager entry. An existing chat is left + // without one until the user changes something, so its state keeps deriving + // from what the server says it last ran with (useComposer's seed) rather + // than being pinned to whatever the defaults were at mount — which, after a + // reload, silently moved every chat onto the default model. useEffect(() => { - initializeComposerForChat(composerChatId) + if (composerChatId === NEW_CHAT_COMPOSER_ID) initializeComposerForChat(composerChatId) }, [composerChatId, initializeComposerForChat]) useEffect(() => { diff --git a/src/client/hooks/useComposer.ts b/src/client/hooks/useComposer.ts index 8334ebd8d..db4012997 100644 --- a/src/client/hooks/useComposer.ts +++ b/src/client/hooks/useComposer.ts @@ -11,8 +11,33 @@ import { import { NEW_CHAT_COMPOSER_ID, useChatPreferencesStore, + type ComposerSeed, type ComposerState, } from "../stores/chatPreferencesStore" +import { useSidebarStore } from "../stores/sidebarStore" + +/** + * The chat's own record of what it last ran with, from the sidebar snapshot. + * Nothing about the composer is persisted on this side, so after a reload (or + * on another device) this is the only thing that knows the chat wasn't on the + * default model. + */ +function useChatComposerSeed(chatId: string | null): ComposerSeed | null { + const row = useSidebarStore((store) => { + if (!chatId) return null + for (const group of store.data.projectGroups) { + const chat = group.chats.find((candidate) => candidate.chatId === chatId) + if (chat) return chat + } + return null + }) + const provider = row?.provider ?? null + const model = row?.model + return useMemo( + () => (provider ? { provider, ...(model ? { model } : {}) } : null), + [provider, model] + ) +} export interface ComposerController extends ComposerView { /** Availability + current values of the per-model option controls. */ @@ -53,14 +78,15 @@ export function useComposer(args: { }): ComposerController { const { chatId, activeProvider, availableProviders } = args const composerChatId = chatId ?? NEW_CHAT_COMPOSER_ID + const seed = useChatComposerSeed(chatId) const storedComposerState = useChatPreferencesStore((store) => store.chatStates[composerChatId]) const providerDefaults = useChatPreferencesStore((store) => store.providerDefaults) const providerSwitchRequested = useChatPreferencesStore( (store) => Boolean(store.pendingProviderSwitches[composerChatId]) ) const composerState = useMemo( - () => storedComposerState ?? useChatPreferencesStore.getState().getComposerState(composerChatId), - [composerChatId, storedComposerState] + () => storedComposerState ?? useChatPreferencesStore.getState().getComposerState(composerChatId, seed), + [composerChatId, seed, storedComposerState] ) // Housekeeping: once the server confirms the switch (the chat's session @@ -79,8 +105,9 @@ export function useComposer(args: { composerState, providerDefaults, providerSwitchRequested, + chatModel: seed && seed.provider === activeProvider ? seed.model : undefined, }), - [activeProvider, availableProviders, chatId, composerState, providerDefaults, providerSwitchRequested] + [activeProvider, availableProviders, chatId, composerState, providerDefaults, providerSwitchRequested, seed] ) const updateEffectiveState = useCallback((transform: (state: ComposerState) => ComposerState) => { diff --git a/src/client/lib/composer.test.ts b/src/client/lib/composer.test.ts index c8353fefc..17c0d4e87 100644 --- a/src/client/lib/composer.test.ts +++ b/src/client/lib/composer.test.ts @@ -276,3 +276,20 @@ describe("getEffectiveComposerState", () => { expect(effective.planMode).toBe(true) }) }) + +describe("getEffectiveComposerState", () => { + test("prefers the chat's own model over the provider default when the provider is realigned", () => { + // A stored state for another provider is realigned to the session's + // provider. With the chat's last model known, that is what it realigns + // to — not the settings default, which the chat may never have used. + const stored: ComposerState = { + provider: "codex", + model: "gpt-5.5", + modelOptions: { reasoningEffort: "low", fastMode: false }, + planMode: false, + autoPlan: false, + } + expect(getEffectiveComposerState(stored, "claude", providerDefaults, "opus").model).toBe("opus") + expect(getEffectiveComposerState(stored, "claude", providerDefaults).model).toBe(providerDefaults.claude.model) + }) +}) diff --git a/src/client/lib/composer.ts b/src/client/lib/composer.ts index 13fa7e0d8..ddd184bbe 100644 --- a/src/client/lib/composer.ts +++ b/src/client/lib/composer.ts @@ -71,7 +71,9 @@ export function applyModelToComposerState(state: ComposerState, model: string): export function getEffectiveComposerState( composerState: ComposerState, activeProvider: AgentProvider | null, - providerDefaults: ChatProviderPreferences + providerDefaults: ChatProviderPreferences, + /** The model the chat last ran with on `activeProvider`, when known. */ + chatModel?: string ): ComposerState { if (!activeProvider || composerState.provider === activeProvider) { return composerState @@ -81,7 +83,7 @@ export function getEffectiveComposerState( case "claude": return { provider: "claude", - model: providerDefaults.claude.model, + model: chatModel ?? providerDefaults.claude.model, modelOptions: { ...providerDefaults.claude.modelOptions }, planMode: composerState.planMode, autoPlan: composerState.autoPlan, @@ -89,7 +91,7 @@ export function getEffectiveComposerState( case "codex": return { provider: "codex", - model: providerDefaults.codex.model, + model: chatModel ?? providerDefaults.codex.model, modelOptions: { ...providerDefaults.codex.modelOptions }, planMode: composerState.planMode, autoPlan: composerState.autoPlan, @@ -97,7 +99,7 @@ export function getEffectiveComposerState( case "cursor": return { provider: "cursor", - model: providerDefaults.cursor.model, + model: chatModel ?? providerDefaults.cursor.model, modelOptions: { ...providerDefaults.cursor.modelOptions }, planMode: composerState.planMode, autoPlan: composerState.autoPlan, @@ -105,7 +107,7 @@ export function getEffectiveComposerState( case "pi": return { provider: "pi", - model: providerDefaults.pi.model, + model: chatModel ?? providerDefaults.pi.model, modelOptions: { ...providerDefaults.pi.modelOptions }, planMode: composerState.planMode, autoPlan: composerState.autoPlan, @@ -147,6 +149,8 @@ export function deriveComposerView(args: { providerDefaults: ChatProviderPreferences /** The user explicitly picked this chat's composer provider (vs. seeded state). */ providerSwitchRequested?: boolean + /** The model the chat last ran with on its session provider, when known. */ + chatModel?: string }): ComposerView { const composerChatId = args.chatId ?? NEW_CHAT_COMPOSER_ID const providerSwitchPending = Boolean(args.providerSwitchRequested) @@ -157,7 +161,7 @@ export function deriveComposerView(args: { // provider — same fallback as before switching existed. const effectiveState = providerSwitchPending ? args.composerState - : getEffectiveComposerState(args.composerState, args.activeProvider, args.providerDefaults) + : getEffectiveComposerState(args.composerState, args.activeProvider, args.providerDefaults, args.chatModel) const selectedProvider = effectiveState.provider const providerConfig = args.availableProviders.find((provider) => provider.id === selectedProvider) ?? args.availableProviders[0] diff --git a/src/client/stores/chatPreferencesStore.test.ts b/src/client/stores/chatPreferencesStore.test.ts index 1ac4a8a60..4ef9e04cb 100644 --- a/src/client/stores/chatPreferencesStore.test.ts +++ b/src/client/stores/chatPreferencesStore.test.ts @@ -1,3 +1,5 @@ +import { useSidebarStore } from "./sidebarStore" +import type { SidebarProjectGroup } from "../../shared/types" import { afterEach, describe, expect, test } from "bun:test" import { migrateChatPreferencesState, @@ -537,10 +539,14 @@ describe("chat preference store", () => { }) }) - test("syncProviderDefaults refreshes untouched routed chat state after settings hydration", () => { + test("syncProviderDefaults leaves a routed chat's state alone", () => { + // Defaults are for starting chats. A chat that already has state — even + // state equal to the old defaults — keeps it when the defaults move; + // otherwise changing a setting would swap the model mid-conversation. const store = useChatPreferencesStore.getState() store.initializeComposerForChat("chat-a") + const before = store.getComposerState("chat-a") store.syncProviderDefaults("last_used", { ...INITIAL_STATE.providerDefaults, claude: { @@ -551,13 +557,61 @@ describe("chat preference store", () => { }, }) - expect(useChatPreferencesStore.getState().getComposerState("chat-a")).toEqual({ - provider: "claude", - model: "opus", - modelOptions: { reasoningEffort: "max", contextWindow: "1m", fastMode: false }, + expect(useChatPreferencesStore.getState().getComposerState("chat-a")).toEqual(before) + }) + + test("mutation helpers seed an un-stored chat from the sidebar row, not the defaults", () => { + // Shift+Tab (mode cycle) and plan approval write through the store's own + // helpers, which never see the hook's seed. They must still start from the + // chat's record, or the write pins the default provider on its way through. + useChatPreferencesStore.setState({ ...INITIAL_STATE, defaultProvider: "claude" }) + useSidebarStore.setState({ + data: { + projectGroups: [{ + chats: [{ chatId: "chat-a", provider: "codex", model: "gpt-5.5" }], + } as unknown as SidebarProjectGroup], + }, + } as never) + try { + useChatPreferencesStore.getState().setChatComposerMode("chat-a", "plan") + const stored = useChatPreferencesStore.getState().chatStates["chat-a"] + expect(stored?.provider).toBe("codex") + expect(stored?.model).toBe("gpt-5.5") + expect(stored?.planMode).toBe(true) + } finally { + useSidebarStore.setState({ data: { projectGroups: [] } } as never) + } + }) + + test("getComposerState seeds an existing chat from what it last ran with", () => { + // Nothing is stored for the chat (a reload emptied this store, or this is + // another device). The chat's own record wins over the settings defaults. + useChatPreferencesStore.setState({ + ...INITIAL_STATE, + defaultProvider: "claude", + providerDefaults: { + ...INITIAL_STATE.providerDefaults, + codex: { + model: "gpt-5.3-codex-spark", + modelOptions: { reasoningEffort: "minimal", fastMode: true }, + planMode: true, + autoPlan: false, + }, + }, + }) + + expect(useChatPreferencesStore.getState().getComposerState("chat-a", { provider: "codex", model: "gpt-5.5" })).toEqual({ + provider: "codex", + model: "gpt-5.5", + // Options aren't recorded per chat, so those are the provider's defaults. + modelOptions: { reasoningEffort: "minimal", fastMode: true }, planMode: true, autoPlan: false, }) + // Stored state, once the user has touched the composer, still wins. + useChatPreferencesStore.getState().setChatComposerModel("chat-a", "gpt-5.3-codex-spark") + expect(useChatPreferencesStore.getState().getComposerState("chat-a", { provider: "codex", model: "gpt-5.5" }).model) + .toBe("gpt-5.3-codex-spark") }) test("syncProviderDefaults does not replace a changed new-chat state", () => { diff --git a/src/client/stores/chatPreferencesStore.ts b/src/client/stores/chatPreferencesStore.ts index acac41b98..c6e2d454b 100644 --- a/src/client/stores/chatPreferencesStore.ts +++ b/src/client/stores/chatPreferencesStore.ts @@ -24,6 +24,7 @@ import { type ProviderModelOptionsInput, type ProviderPreferenceInput, } from "../../shared/provider-preferences" +import { findSidebarChat } from "./sidebarStore" export type { ChatProviderPreferences, DefaultProviderPreference, ProviderPreference } // The normalizers live in shared/provider-preferences (also used by the server's @@ -92,6 +93,26 @@ function composerFromProviderDefaults( return composerStateForProvider(provider, providerDefaults[provider]) } +/** + * What the server knows a chat last ran with — the sidebar row's provider and + * model. Nothing else survives a reload on this side, so this is what an + * existing chat is seeded from. Settings defaults are for chats that have + * never run; a chat that has picks up where it left off, on every device. + */ +export interface ComposerSeed { + provider: AgentProvider + model?: string +} + +function composerFromChatSeed(seed: ComposerSeed, providerDefaults: ChatProviderPreferences): ComposerState { + // Options (effort, context window…) aren't recorded per chat, so those still + // come from the provider's defaults; the model is the chat's own. + return composerStateForProvider(seed.provider, { + ...providerDefaults[seed.provider], + ...(seed.model ? { model: seed.model } : {}), + }) +} + function cloneComposerState(state: ComposerState): ComposerState { return { ...state, modelOptions: { ...state.modelOptions } } as ComposerState } @@ -169,14 +190,32 @@ function createComposerStateForNewChat(args: { return composerFromProviderDefaults(args.defaultProvider, args.providerDefaults) } +/** + * The seed for a chat with nothing stored, read from the sidebar snapshot. + * Every path that materialises state for a chat — including the mutation + * helpers, which never see the hook's reactive seed — must start from the + * chat's own record, or a Shift+Tab on a chat you just reloaded would pin it + * to the default provider on its way to toggling the mode. + */ +function seedForChat(chatId: string): ComposerSeed | null { + if (chatId === NEW_CHAT_COMPOSER_ID) return null + const row = findSidebarChat(chatId) + if (!row?.provider) return null + return { provider: row.provider, ...(row.model ? { model: row.model } : {}) } +} + function getStoredComposerState( state: Pick, - chatId: string + chatId: string, + seed: ComposerSeed | null = seedForChat(chatId) ): ComposerState { const existingState = state.chatStates[chatId] if (existingState) { return existingState } + if (seed) { + return composerFromChatSeed(seed, state.providerDefaults) + } return createComposerStateForNewChat({ defaultProvider: state.defaultProvider, @@ -219,7 +258,8 @@ interface ChatPreferencesState { modelOptions: Partial ) => void setProviderDefaultMode: (provider: AgentProvider, mode: ChatMode) => void - getComposerState: (chatId: string) => ComposerState + /** `seed` is the chat's own record (see ComposerSeed); used only when nothing is stored for it. */ + getComposerState: (chatId: string, seed?: ComposerSeed | null) => ComposerState initializeComposerForChat: (chatId: string, options?: { sourceState?: ComposerState | null }) => void setComposerState: (chatId: string, composerState: ComposerState) => void setChatComposerProvider: (chatId: string, provider: AgentProvider) => void @@ -285,12 +325,13 @@ export const useChatPreferencesStore = create()( providerDefaults, legacyComposerState: state.legacyComposerState, }) - const chatStates = Object.fromEntries( - Object.entries(state.chatStates).map(([chatId, composerState]) => [ - chatId, - sameComposerState(composerState, oldNewChatFallback) ? nextNewChatFallback : composerState, - ]) - ) + // Only the new-chat composer follows a change of defaults, and only + // while it is still untouched. A chat that has run keeps what it ran + // with: defaults are for starting chats, not for changing them. + const newChatState = state.chatStates[NEW_CHAT_COMPOSER_ID] + const chatStates = newChatState && sameComposerState(newChatState, oldNewChatFallback) + ? { ...state.chatStates, [NEW_CHAT_COMPOSER_ID]: nextNewChatFallback } + : state.chatStates return { defaultProvider, @@ -328,7 +369,7 @@ export const useChatPreferencesStore = create()( }, }, })), - getComposerState: (chatId) => cloneComposerState(getStoredComposerState(get(), chatId)), + getComposerState: (chatId, seed) => cloneComposerState(getStoredComposerState(get(), chatId, seed)), initializeComposerForChat: (chatId, options) => set((state) => { if (state.chatStates[chatId]) {