From 3954e8594d0687805be044117fab895251514b2e Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 23 Nov 2025 19:03:25 -0600 Subject: [PATCH 1/6] feat: explicit default model selection - Replace implicit LRU default behavior with explicit user selection - Add star button to model selector to set default model - Ensure default model cannot be deleted or unset (only replaced) - Persist default model separately from LRU list --- src/browser/components/ChatInput/index.tsx | 4 +- .../components/ModelSelector.stories.tsx | 15 +++++ src/browser/components/ModelSelector.tsx | 56 +++++++++++++++---- src/browser/hooks/useModelLRU.ts | 38 +++++++++++-- 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index dab34e1d81..b773e3783c 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -138,7 +138,7 @@ export const ChatInput: React.FC = (props) => { const inputRef = useRef(null); const modelSelectorRef = useRef(null); const [mode, setMode] = useMode(); - const { recentModels, addModel, evictModel } = useModelLRU(); + const { recentModels, addModel, evictModel, defaultModel, setDefaultModel } = useModelLRU(); const commandListId = useId(); const telemetry = useTelemetry(); const [vimEnabled, setVimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { @@ -967,6 +967,8 @@ export const ChatInput: React.FC = (props) => { recentModels={recentModels} onRemoveModel={evictModel} onComplete={() => inputRef.current?.focus()} + defaultModel={defaultModel} + onSetDefaultModel={setDefaultModel} /> ? diff --git a/src/browser/components/ModelSelector.stories.tsx b/src/browser/components/ModelSelector.stories.tsx index 201c7efffe..ca236ce235 100644 --- a/src/browser/components/ModelSelector.stories.tsx +++ b/src/browser/components/ModelSelector.stories.tsx @@ -77,3 +77,18 @@ export const WithManyModels: Story = { onComplete: action("onComplete"), }, }; + +export const WithDefaultModel: Story = { + args: { + value: "anthropic:claude-sonnet-4-5", + onChange: action("onChange"), + onRemoveModel: action("onRemoveModel"), + recentModels: ["anthropic:claude-sonnet-4-5", "anthropic:claude-opus-4-1", "openai:gpt-5-pro"], + onComplete: action("onComplete"), + defaultModel: "anthropic:claude-opus-4-1", + onSetDefaultModel: (model) => { + // Mimic the hook behavior - only allow setting, not clearing + if (model) action("onSetDefaultModel")(model); + }, + }, +}; diff --git a/src/browser/components/ModelSelector.tsx b/src/browser/components/ModelSelector.tsx index 4e5230d1be..a7de859a25 100644 --- a/src/browser/components/ModelSelector.tsx +++ b/src/browser/components/ModelSelector.tsx @@ -7,6 +7,7 @@ import React, { forwardRef, } from "react"; import { cn } from "@/common/lib/utils"; +import { Star } from "lucide-react"; interface ModelSelectorProps { value: string; @@ -14,6 +15,8 @@ interface ModelSelectorProps { recentModels: string[]; onRemoveModel?: (model: string) => void; onComplete?: () => void; + defaultModel?: string | null; + onSetDefaultModel?: (model: string | null) => void; } export interface ModelSelectorRef { @@ -21,7 +24,10 @@ export interface ModelSelectorRef { } export const ModelSelector = forwardRef( - ({ value, onChange, recentModels, onRemoveModel, onComplete }, ref) => { + ( + { value, onChange, recentModels, onRemoveModel, onComplete, defaultModel, onSetDefaultModel }, + ref + ) => { const [isEditing, setIsEditing] = useState(false); const [inputValue, setInputValue] = useState(value); const [error, setError] = useState(null); @@ -243,16 +249,44 @@ export const ModelSelector = forwardRef( >
{model} - {onRemoveModel && ( - - )} +
+ {onSetDefaultModel && ( + + )} + {onRemoveModel && defaultModel !== model && ( + + )} +
))} diff --git a/src/browser/hooks/useModelLRU.ts b/src/browser/hooks/useModelLRU.ts index 67da7897bd..ee9944dc93 100644 --- a/src/browser/hooks/useModelLRU.ts +++ b/src/browser/hooks/useModelLRU.ts @@ -6,6 +6,7 @@ import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; const MAX_LRU_SIZE = 12; const LRU_KEY = "model-lru"; +const DEFAULT_MODEL_KEY = "model-default"; // Ensure defaultModel is first, then fill with other abbreviations (deduplicated) const FALLBACK_MODEL = WORKSPACE_DEFAULTS.model ?? defaultModel; @@ -13,6 +14,7 @@ const DEFAULT_MODELS = [ FALLBACK_MODEL, ...Array.from(new Set(Object.values(MODEL_ABBREVIATIONS))).filter((m) => m !== FALLBACK_MODEL), ].slice(0, MAX_LRU_SIZE); + function persistModels(models: string[]): void { updatePersistedState(LRU_KEY, models.slice(0, MAX_LRU_SIZE)); } @@ -31,15 +33,19 @@ export function evictModelFromLRU(model: string): void { persistModels(nextList); } +export function getExplicitDefaultModel(): string { + const persisted = readPersistedState(DEFAULT_MODEL_KEY, null); + return persisted ?? FALLBACK_MODEL; +} + /** - * Get the default model from LRU (non-hook version for use outside React) - * This is the ONLY place that reads from LRU outside of the hook. - * - * @returns The most recently used model, or WORKSPACE_DEFAULTS.model if LRU is empty + * Get the default model. + * Prioritizes the explicit default model if set. + * Otherwise, falls back to LRU behavior (most recently used). */ export function getDefaultModelFromLRU(): string { - const lru = readPersistedState(LRU_KEY, DEFAULT_MODELS.slice(0, MAX_LRU_SIZE)); - return lru[0] ?? FALLBACK_MODEL; + // Always return the explicit default (which falls back to hardcoded default) + return getExplicitDefaultModel(); } /** @@ -54,6 +60,24 @@ export function useModelLRU() { { listener: true } ); + const [defaultModel, setDefaultModel] = usePersistedState( + DEFAULT_MODEL_KEY, + null, + { listener: true } + ); + + // Return the effective default model (never null) + const effectiveDefaultModel = defaultModel ?? FALLBACK_MODEL; + + // Wrapper for setDefaultModel that prevents null/clearing + const handleSetDefaultModel = useCallback( + (model: string | null) => { + if (!model) return; + setDefaultModel(model); + }, + [setDefaultModel] + ); + // Merge any new defaults from MODEL_ABBREVIATIONS (only once on mount) useEffect(() => { setRecentModels((prev) => { @@ -107,5 +131,7 @@ export function useModelLRU() { evictModel, getRecentModels, recentModels, + defaultModel: effectiveDefaultModel, + setDefaultModel: handleSetDefaultModel, }; } From 92c47ec81f3d2c5051a66acbccb8dfae439a3f33 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 23 Nov 2025 20:43:46 -0600 Subject: [PATCH 2/6] fix: model selector layout and focus behavior --- src/browser/components/ModelSelector.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/browser/components/ModelSelector.tsx b/src/browser/components/ModelSelector.tsx index a7de859a25..abb4aaafed 100644 --- a/src/browser/components/ModelSelector.tsx +++ b/src/browser/components/ModelSelector.tsx @@ -247,12 +247,13 @@ export const ModelSelector = forwardRef( )} onClick={() => handleSelectModel(model)} > -
- {model} -
+
+ {model} +
{onSetDefaultModel && ( + disabled={defaultModel === model} + > + + + + {defaultModel === model ? "Current default model" : "Set as default model"} + + )} {onRemoveModel && defaultModel !== model && ( - {defaultModel === model ? "Current default model" : "Set as default model"} + {defaultModel === model + ? "Current default model" + : "Set as default model"} )} diff --git a/src/browser/hooks/useAIViewKeybinds.ts b/src/browser/hooks/useAIViewKeybinds.ts index 47e86381eb..c70bb39a61 100644 --- a/src/browser/hooks/useAIViewKeybinds.ts +++ b/src/browser/hooks/useAIViewKeybinds.ts @@ -6,7 +6,7 @@ import { updatePersistedState, readPersistedState } from "@/browser/hooks/usePer import type { ThinkingLevel, ThinkingLevelOn } from "@/common/types/thinking"; import { DEFAULT_THINKING_LEVEL } from "@/common/types/thinking"; import { getThinkingPolicyForModel } from "@/browser/utils/thinking/policy"; -import { getDefaultModelFromLRU } from "@/browser/hooks/useModelLRU"; +import { getDefaultModel } from "@/browser/hooks/useModelLRU"; import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator"; import { isCompactingStream, cancelCompaction } from "@/browser/utils/compaction/handler"; @@ -116,7 +116,7 @@ export function useAIViewKeybinds({ // Fall back to message history model, then to most recent model from LRU // This matches the same logic as useSendMessageOptions const selectedModel = readPersistedState(getModelKey(workspaceId), null); - const modelToUse = selectedModel ?? currentModel ?? getDefaultModelFromLRU(); + const modelToUse = selectedModel ?? currentModel ?? getDefaultModel(); // Storage key for remembering this model's last-used active thinking level const lastThinkingKey = getLastThinkingByModelKey(modelToUse); diff --git a/src/browser/hooks/useModelLRU.ts b/src/browser/hooks/useModelLRU.ts index ee9944dc93..565698eada 100644 --- a/src/browser/hooks/useModelLRU.ts +++ b/src/browser/hooks/useModelLRU.ts @@ -33,21 +33,11 @@ export function evictModelFromLRU(model: string): void { persistModels(nextList); } -export function getExplicitDefaultModel(): string { +export function getDefaultModel(): string { const persisted = readPersistedState(DEFAULT_MODEL_KEY, null); return persisted ?? FALLBACK_MODEL; } -/** - * Get the default model. - * Prioritizes the explicit default model if set. - * Otherwise, falls back to LRU behavior (most recently used). - */ -export function getDefaultModelFromLRU(): string { - // Always return the explicit default (which falls back to hardcoded default) - return getExplicitDefaultModel(); -} - /** * Hook to manage a Least Recently Used (LRU) cache of AI models. * Stores up to 8 recently used models in localStorage. @@ -60,24 +50,12 @@ export function useModelLRU() { { listener: true } ); - const [defaultModel, setDefaultModel] = usePersistedState( + const [defaultModel, setDefaultModel] = usePersistedState( DEFAULT_MODEL_KEY, - null, + FALLBACK_MODEL, { listener: true } ); - // Return the effective default model (never null) - const effectiveDefaultModel = defaultModel ?? FALLBACK_MODEL; - - // Wrapper for setDefaultModel that prevents null/clearing - const handleSetDefaultModel = useCallback( - (model: string | null) => { - if (!model) return; - setDefaultModel(model); - }, - [setDefaultModel] - ); - // Merge any new defaults from MODEL_ABBREVIATIONS (only once on mount) useEffect(() => { setRecentModels((prev) => { @@ -131,7 +109,7 @@ export function useModelLRU() { evictModel, getRecentModels, recentModels, - defaultModel: effectiveDefaultModel, - setDefaultModel: handleSetDefaultModel, + defaultModel, + setDefaultModel, }; } diff --git a/src/browser/hooks/useSendMessageOptions.ts b/src/browser/hooks/useSendMessageOptions.ts index 33472a2d1b..576211c96a 100644 --- a/src/browser/hooks/useSendMessageOptions.ts +++ b/src/browser/hooks/useSendMessageOptions.ts @@ -1,7 +1,7 @@ import { useThinkingLevel } from "./useThinkingLevel"; import { useMode } from "@/browser/contexts/ModeContext"; import { usePersistedState } from "./usePersistedState"; -import { getDefaultModelFromLRU } from "./useModelLRU"; +import { getDefaultModel } from "./useModelLRU"; import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils"; import { getModelKey } from "@/common/constants/storage"; import type { SendMessageOptions } from "@/common/types/ipc"; @@ -56,7 +56,7 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptions { const [thinkingLevel] = useThinkingLevel(); const [mode] = useMode(); const { options: providerOptions } = useProviderOptions(); - const defaultModel = getDefaultModelFromLRU(); + const defaultModel = getDefaultModel(); const [preferredModel] = usePersistedState( getModelKey(workspaceId), defaultModel, // Default to most recently used model diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index 4e4acfb681..b18a2c802c 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -1,7 +1,7 @@ import { getModelKey, getThinkingLevelKey, getModeKey } from "@/common/constants/storage"; import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; -import { getDefaultModelFromLRU } from "@/browser/hooks/useModelLRU"; +import { getDefaultModel } from "@/browser/hooks/useModelLRU"; import type { SendMessageOptions } from "@/common/types/ipc"; import type { UIMode } from "@/common/types/mode"; import type { ThinkingLevel } from "@/common/types/thinking"; @@ -38,7 +38,7 @@ function getProviderOptions(): MuxProviderOptions { */ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptions { // Read model preference (workspace-specific), fallback to LRU default - const model = readPersistedState(getModelKey(workspaceId), getDefaultModelFromLRU()); + const model = readPersistedState(getModelKey(workspaceId), getDefaultModel()); // Read thinking level (workspace-specific) const thinkingLevel = readPersistedState( From 62ce0abad34a3c44b07ffa3e2d7fcb2ef4ec8410 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 23 Nov 2025 21:36:12 -0600 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20reset=20creation=20mo?= =?UTF-8?q?del=20on=20variant=20change;=20align=20star=20with=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Generated with `mux`_ --- src/browser/components/ChatInput/index.tsx | 8 ++++---- src/browser/components/ModelSelector.tsx | 14 ++++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index af1666d39a..b033fff67a 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -176,14 +176,14 @@ export const ChatInput: React.FC = (props) => { [storageKeys.modelKey, addModel] ); - // When entering creation mode, always reset the model selection to the global default. - // We don't want manual selections to persist to subsequent workspace creations. + // 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. useEffect(() => { if (variant === "creation" && defaultModel) { updatePersistedState(storageKeys.modelKey, defaultModel); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Run once on mount + }, [variant, defaultModel, storageKeys.modelKey]); // Creation-specific state (hook always called, but only used when variant === "creation") // This avoids conditional hook calls which violate React rules diff --git a/src/browser/components/ModelSelector.tsx b/src/browser/components/ModelSelector.tsx index 3a39e75ada..b3eb8306a6 100644 --- a/src/browser/components/ModelSelector.tsx +++ b/src/browser/components/ModelSelector.tsx @@ -256,9 +256,9 @@ export const ModelSelector = forwardRef( )} onClick={() => handleSelectModel(model)} > -
- {model} -
+
+ {model} +
{onSetDefaultModel && ( {defaultModel === model