Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/client/components/chat-ui/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -474,8 +474,13 @@ const ChatInputInner = forwardRef<ChatInputHandle, Props>(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(() => {
Expand Down
33 changes: 30 additions & 3 deletions src/client/hooks/useComposer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand All @@ -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) => {
Expand Down
17 changes: 17 additions & 0 deletions src/client/lib/composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
16 changes: 10 additions & 6 deletions src/client/lib/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -81,31 +83,31 @@ 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,
}
case "codex":
return {
provider: "codex",
model: providerDefaults.codex.model,
model: chatModel ?? providerDefaults.codex.model,
modelOptions: { ...providerDefaults.codex.modelOptions },
planMode: composerState.planMode,
autoPlan: composerState.autoPlan,
}
case "cursor":
return {
provider: "cursor",
model: providerDefaults.cursor.model,
model: chatModel ?? providerDefaults.cursor.model,
modelOptions: { ...providerDefaults.cursor.modelOptions },
planMode: composerState.planMode,
autoPlan: composerState.autoPlan,
}
case "pi":
return {
provider: "pi",
model: providerDefaults.pi.model,
model: chatModel ?? providerDefaults.pi.model,
modelOptions: { ...providerDefaults.pi.modelOptions },
planMode: composerState.planMode,
autoPlan: composerState.autoPlan,
Expand Down Expand Up @@ -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)
Expand All @@ -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]
Expand Down
64 changes: 59 additions & 5 deletions src/client/stores/chatPreferencesStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useSidebarStore } from "./sidebarStore"
import type { SidebarProjectGroup } from "../../shared/types"
import { afterEach, describe, expect, test } from "bun:test"
import {
migrateChatPreferencesState,
Expand Down Expand Up @@ -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: {
Expand All @@ -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", () => {
Expand Down
59 changes: 50 additions & 9 deletions src/client/stores/chatPreferencesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 } : {}) }
Comment on lines +202 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Early actions persist defaults

On an initial load or reconnect, the existing-chat composer is interactive before the sidebar snapshot arrives. If the user changes the mode or model, or approves a plan during that window, seedForChat returns null and the mutation stores the global defaults for that chat. Stored state then takes priority over the later sidebar seed, so the snapshot cannot repair it and the next turn can still run with the wrong provider or model. Avoid creating existing-chat state from defaults while its sidebar record is loading, or reconcile that temporary state when the record arrives.

Knowledge Base Used: Chat workspace experience

Fix in Codex

}

function getStoredComposerState(
state: Pick<ChatPreferencesState, "chatStates" | "defaultProvider" | "providerDefaults" | "legacyComposerState">,
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,
Expand Down Expand Up @@ -219,7 +258,8 @@ interface ChatPreferencesState {
modelOptions: Partial<ProviderModelOptionsByProvider[TProvider]>
) => 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
Expand Down Expand Up @@ -285,12 +325,13 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
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,
Expand Down Expand Up @@ -328,7 +369,7 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
},
},
})),
getComposerState: (chatId) => cloneComposerState(getStoredComposerState(get(), chatId)),
getComposerState: (chatId, seed) => cloneComposerState(getStoredComposerState(get(), chatId, seed)),
initializeComposerForChat: (chatId, options) =>
set((state) => {
if (state.chatStates[chatId]) {
Expand Down