diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 0657c75..3b4e633 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -16,6 +16,12 @@ const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] return ['default', 'acceptEdits', 'bypassPermissions', 'plan']; }; +// Per-session model persistence keys. +// Global key (`${provider}-model`) holds the "last used" default for NEW sessions. +// Session key (`${provider}-model-${sessionId}`) pins the model for that session. +const globalModelKey = (provider: string) => `${provider}-model`; +const sessionModelKey = (provider: string, sessionId: string) => `${provider}-model-${sessionId}`; + interface UseChatProviderStateArgs { selectedSession: ProjectSession | null; } @@ -26,18 +32,21 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr const [provider, setProvider] = useState(() => { return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; }); - const [cursorModel, setCursorModel] = useState(() => { - return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT; - }); - const [claudeModel, setClaudeModel] = useState(() => { - return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT; - }); - const [codexModel, setCodexModel] = useState(() => { - return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT; - }); - const [geminiModel, setGeminiModel] = useState(() => { - return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT; - }); + + // Initial model values: prefer the per-session pin if a session is already + // selected on mount, otherwise the global last-used default. + const initialModel = (p: string, fallback: string) => { + if (selectedSession?.id) { + const pinned = localStorage.getItem(sessionModelKey(p, selectedSession.id)); + if (pinned) return pinned; + } + return localStorage.getItem(globalModelKey(p)) || fallback; + }; + + const [cursorModel, setCursorModelState] = useState(() => initialModel('cursor', CURSOR_MODELS.DEFAULT)); + const [claudeModel, setClaudeModelState] = useState(() => initialModel('claude', CLAUDE_MODELS.DEFAULT)); + const [codexModel, setCodexModelState] = useState(() => initialModel('codex', CODEX_MODELS.DEFAULT)); + const [geminiModel, setGeminiModelState] = useState(() => initialModel('gemini', GEMINI_MODELS.DEFAULT)); // Live model lists — fall back to static constants until API responds const [claudeModelOptions, setClaudeModelOptions] = useState(CLAUDE_MODELS.OPTIONS); @@ -45,7 +54,43 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr const [geminiModelOptions] = useState(GEMINI_MODELS.OPTIONS); const [cursorModelOptions] = useState(CURSOR_MODELS.OPTIONS); - // Fetch live model list and validate the saved claude model + // Persisted setters: write BOTH the per-session pin (if a session is active) + // and the global last-used default, then update state. + const makePersistedSetter = useCallback( + (p: string, setState: (v: string) => void) => (value: string) => { + setState(value); + localStorage.setItem(globalModelKey(p), value); + if (selectedSession?.id) { + localStorage.setItem(sessionModelKey(p, selectedSession.id), value); + } + }, + [selectedSession?.id], + ); + + const setCursorModel = useCallback(makePersistedSetter('cursor', setCursorModelState), [makePersistedSetter]); + const setClaudeModel = useCallback(makePersistedSetter('claude', setClaudeModelState), [makePersistedSetter]); + const setCodexModel = useCallback(makePersistedSetter('codex', setCodexModelState), [makePersistedSetter]); + const setGeminiModel = useCallback(makePersistedSetter('gemini', setGeminiModelState), [makePersistedSetter]); + + // When the selected session changes, load each provider's pinned model for + // that session (falling back to the global default). This is what stops a + // model picked in session A from leaking into session B. + useEffect(() => { + if (!selectedSession?.id) { + return; + } + const load = (p: string, setState: (v: string) => void, fallback: string) => { + const pinned = localStorage.getItem(sessionModelKey(p, selectedSession.id)); + const next = pinned || localStorage.getItem(globalModelKey(p)) || fallback; + setState(next); + }; + load('cursor', setCursorModelState, CURSOR_MODELS.DEFAULT); + load('claude', setClaudeModelState, CLAUDE_MODELS.DEFAULT); + load('codex', setCodexModelState, CODEX_MODELS.DEFAULT); + load('gemini', setGeminiModelState, GEMINI_MODELS.DEFAULT); + }, [selectedSession?.id]); + + // Fetch live Claude model list and validate the current claude model useEffect(() => { authenticatedFetch('/api/models') .then((res) => { @@ -57,18 +102,21 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr const options: ModelOption[] = data.claude; setClaudeModelOptions(options); - // Validate saved model — if it's no longer in the list, reset to default - setClaudeModel((current) => { + setClaudeModelState((current) => { const valid = options.some((o) => o.value === current); if (valid) return current; const fallback = options[0]?.value ?? CLAUDE_MODELS.DEFAULT; - localStorage.setItem('claude-model', fallback); + localStorage.setItem(globalModelKey('claude'), fallback); + if (selectedSession?.id) { + localStorage.setItem(sessionModelKey('claude', selectedSession.id), fallback); + } return fallback; }); }) .catch(() => { - // Static fallback already in place — nothing to do + // Static fallback already in place }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const lastProviderRef = useRef(provider); @@ -119,8 +167,8 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr } const modelId = data.config.model.modelId as string; - if (!localStorage.getItem('cursor-model')) { - setCursorModel(modelId); + if (!localStorage.getItem(globalModelKey('cursor'))) { + setCursorModelState(modelId); } }) .catch((error) => {