From 9e43af6022dede575156a17ad31c1d7a01c093ac Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Fri, 4 Sep 2026 17:18:26 -0400 Subject: [PATCH] fix(composer): keep a chat on the model it last ran with after a reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat-preferences store isn't persisted, so a reload emptied every chat's composer state. The composer then re-seeded each chat from the Settings defaults on mount, and the next send ran on the default model β€” which looked random because reloads are: a self-restart after an update, ⌘R, opening a link, another device. The server already records what each chat last ran with (the sidebar row's provider and model). An existing chat with nothing stored now derives its composer state from that, on every path that materialises state β€” the hook, and the store's own mutation helpers, which Shift+Tab and plan approval write through and which never see the hook's seed. Only the new-chat composer is seeded eagerly from defaults. syncProviderDefaults no longer rewrites a routed chat whose state happens to equal the old defaults. Defaults are for starting chats, not for changing a running one. 🌸 Shipped with Kanna β€” https://kanna.sh Co-Authored-By: Kanna Kanna-Agent: claude/fable --- src/client/components/chat-ui/ChatInput.tsx | 9 ++- src/client/hooks/useComposer.ts | 33 +++++++++- src/client/lib/composer.test.ts | 17 +++++ src/client/lib/composer.ts | 16 +++-- .../stores/chatPreferencesStore.test.ts | 64 +++++++++++++++++-- src/client/stores/chatPreferencesStore.ts | 59 ++++++++++++++--- 6 files changed, 173 insertions(+), 25 deletions(-) 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]) {