Skip to content

Commit 0d6339d

Browse files
authored
🤖 feat: explicit default model selection (#710)
_Generated with mux_ ## Description Changes the default model behavior from implicit (LRU-based) to explicit user selection. ## Changes - **Explicit Default**: Users can now star a model in the selector to make it the default. - **Persistence**: Default model is stored separately from the LRU list. - **Safety**: - Default model cannot be null (falls back to system default). - Default model cannot be deleted from the list. - Default status cannot be unset (only replaced by selecting a new default). - **UI**: Added star icon button to model selector rows. ## Testing - Verified model selector UI interactions (star/unstar/delete). - Checked persistence across reloads. - Verified fallback behavior when no explicit default is set (initially).
1 parent 790ef06 commit 0d6339d

File tree

7 files changed

+105
-29
lines changed

7 files changed

+105
-29
lines changed

src/browser/components/ChatInput/index.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
138138
const inputRef = useRef<HTMLTextAreaElement>(null);
139139
const modelSelectorRef = useRef<ModelSelectorRef>(null);
140140
const [mode, setMode] = useMode();
141-
const { recentModels, addModel, evictModel } = useModelLRU();
141+
const { recentModels, addModel, evictModel, defaultModel, setDefaultModel } = useModelLRU();
142142
const commandListId = useId();
143143
const telemetry = useTelemetry();
144144
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
@@ -176,6 +176,15 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
176176
[storageKeys.modelKey, addModel]
177177
);
178178

179+
// When entering creation mode (or when the default model changes), reset the
180+
// project-scoped model to the explicit default so manual picks don't bleed
181+
// into subsequent creation flows.
182+
useEffect(() => {
183+
if (variant === "creation" && defaultModel) {
184+
updatePersistedState(storageKeys.modelKey, defaultModel);
185+
}
186+
}, [variant, defaultModel, storageKeys.modelKey]);
187+
179188
// Creation-specific state (hook always called, but only used when variant === "creation")
180189
// This avoids conditional hook calls which violate React rules
181190
const creationState = useCreationWorkspace(
@@ -967,6 +976,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
967976
recentModels={recentModels}
968977
onRemoveModel={evictModel}
969978
onComplete={() => inputRef.current?.focus()}
979+
defaultModel={defaultModel}
980+
onSetDefaultModel={setDefaultModel}
970981
/>
971982
<TooltipWrapper inline>
972983
<HelpIndicator>?</HelpIndicator>

src/browser/components/ModelSelector.stories.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,18 @@ export const WithManyModels: Story = {
7777
onComplete: action("onComplete"),
7878
},
7979
};
80+
81+
export const WithDefaultModel: Story = {
82+
args: {
83+
value: "anthropic:claude-sonnet-4-5",
84+
onChange: action("onChange"),
85+
onRemoveModel: action("onRemoveModel"),
86+
recentModels: ["anthropic:claude-sonnet-4-5", "anthropic:claude-opus-4-1", "openai:gpt-5-pro"],
87+
onComplete: action("onComplete"),
88+
defaultModel: "anthropic:claude-opus-4-1",
89+
onSetDefaultModel: (model) => {
90+
// Mimic the hook behavior - only allow setting, not clearing
91+
if (model) action("onSetDefaultModel")(model);
92+
},
93+
},
94+
};

src/browser/components/ModelSelector.tsx

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,28 @@ import React, {
77
forwardRef,
88
} from "react";
99
import { cn } from "@/common/lib/utils";
10+
import { Star } from "lucide-react";
11+
import { TooltipWrapper, Tooltip } from "./Tooltip";
1012

1113
interface ModelSelectorProps {
1214
value: string;
1315
onChange: (value: string) => void;
1416
recentModels: string[];
1517
onRemoveModel?: (model: string) => void;
1618
onComplete?: () => void;
19+
defaultModel?: string | null;
20+
onSetDefaultModel?: (model: string) => void;
1721
}
1822

1923
export interface ModelSelectorRef {
2024
open: () => void;
2125
}
2226

2327
export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
24-
({ value, onChange, recentModels, onRemoveModel, onComplete }, ref) => {
28+
(
29+
{ value, onChange, recentModels, onRemoveModel, onComplete, defaultModel, onSetDefaultModel },
30+
ref
31+
) => {
2532
const [isEditing, setIsEditing] = useState(false);
2633
const [inputValue, setInputValue] = useState(value);
2734
const [error, setError] = useState<string | null>(null);
@@ -179,6 +186,14 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
179186
setHighlightedIndex(currentIndex);
180187
}, [recentModels, value]);
181188

189+
const handleSetDefault = (e: React.MouseEvent, model: string) => {
190+
e.preventDefault();
191+
e.stopPropagation();
192+
if (defaultModel !== model && onSetDefaultModel) {
193+
onSetDefaultModel(model);
194+
}
195+
};
196+
182197
// Expose open method to parent via ref
183198
useImperativeHandle(
184199
ref,
@@ -241,18 +256,49 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
241256
)}
242257
onClick={() => handleSelectModel(model)}
243258
>
244-
<div className="flex items-center justify-between gap-2">
245-
<span className="truncate">{model}</span>
246-
{onRemoveModel && (
247-
<button
248-
type="button"
249-
onClick={(event) => handleRemoveModel(model, event)}
250-
className="text-muted-light border-border-light/40 hover:border-danger-soft/60 hover:text-danger-soft rounded-sm border px-1 py-0.5 text-[9px] font-semibold tracking-wide uppercase transition-colors duration-150"
251-
aria-label={`Remove ${model} from recent models`}
252-
>
253-
×
254-
</button>
255-
)}
259+
<div className="grid w-full grid-cols-[1fr_48px] items-center gap-2">
260+
<span className="min-w-0 truncate">{model}</span>
261+
<div className="grid w-[48px] grid-cols-[22px_22px] justify-items-center gap-1">
262+
{onSetDefaultModel && (
263+
<TooltipWrapper inline>
264+
<button
265+
type="button"
266+
onMouseDown={(e) => e.preventDefault()}
267+
onClick={(e) => handleSetDefault(e, model)}
268+
className={cn(
269+
"flex items-center justify-center rounded-sm border px-1 py-0.5 transition-colors duration-150",
270+
defaultModel === model
271+
? "text-yellow-400 border-yellow-400/40 cursor-default"
272+
: "text-muted-light border-border-light/40 hover:border-foreground/60 hover:text-foreground"
273+
)}
274+
aria-label={
275+
defaultModel === model
276+
? "Current default model"
277+
: "Set as default model"
278+
}
279+
disabled={defaultModel === model}
280+
>
281+
<Star className="h-3 w-3" />
282+
</button>
283+
<Tooltip className="tooltip" align="center">
284+
{defaultModel === model
285+
? "Current default model"
286+
: "Set as default model"}
287+
</Tooltip>
288+
</TooltipWrapper>
289+
)}
290+
{onRemoveModel && defaultModel !== model && (
291+
<button
292+
type="button"
293+
onMouseDown={(e) => e.preventDefault()}
294+
onClick={(event) => handleRemoveModel(model, event)}
295+
className="text-muted-light border-border-light/40 hover:border-danger-soft/60 hover:text-danger-soft rounded-sm border px-1 py-0.5 text-[9px] font-semibold tracking-wide uppercase transition-colors duration-150"
296+
aria-label={`Remove ${model} from recent models`}
297+
>
298+
×
299+
</button>
300+
)}
301+
</div>
256302
</div>
257303
</div>
258304
))}

src/browser/hooks/useAIViewKeybinds.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { updatePersistedState, readPersistedState } from "@/browser/hooks/usePer
66
import type { ThinkingLevel, ThinkingLevelOn } from "@/common/types/thinking";
77
import { DEFAULT_THINKING_LEVEL } from "@/common/types/thinking";
88
import { getThinkingPolicyForModel } from "@/browser/utils/thinking/policy";
9-
import { getDefaultModelFromLRU } from "@/browser/hooks/useModelLRU";
9+
import { getDefaultModel } from "@/browser/hooks/useModelLRU";
1010
import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
1111
import { isCompactingStream, cancelCompaction } from "@/browser/utils/compaction/handler";
1212

@@ -116,7 +116,7 @@ export function useAIViewKeybinds({
116116
// Fall back to message history model, then to most recent model from LRU
117117
// This matches the same logic as useSendMessageOptions
118118
const selectedModel = readPersistedState<string | null>(getModelKey(workspaceId), null);
119-
const modelToUse = selectedModel ?? currentModel ?? getDefaultModelFromLRU();
119+
const modelToUse = selectedModel ?? currentModel ?? getDefaultModel();
120120

121121
// Storage key for remembering this model's last-used active thinking level
122122
const lastThinkingKey = getLastThinkingByModelKey(modelToUse);

src/browser/hooks/useModelLRU.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";
66

77
const MAX_LRU_SIZE = 12;
88
const LRU_KEY = "model-lru";
9+
const DEFAULT_MODEL_KEY = "model-default";
910

1011
// Ensure defaultModel is first, then fill with other abbreviations (deduplicated)
1112
const FALLBACK_MODEL = WORKSPACE_DEFAULTS.model ?? defaultModel;
1213
const DEFAULT_MODELS = [
1314
FALLBACK_MODEL,
1415
...Array.from(new Set(Object.values(MODEL_ABBREVIATIONS))).filter((m) => m !== FALLBACK_MODEL),
1516
].slice(0, MAX_LRU_SIZE);
17+
1618
function persistModels(models: string[]): void {
1719
updatePersistedState(LRU_KEY, models.slice(0, MAX_LRU_SIZE));
1820
}
@@ -31,15 +33,9 @@ export function evictModelFromLRU(model: string): void {
3133
persistModels(nextList);
3234
}
3335

34-
/**
35-
* Get the default model from LRU (non-hook version for use outside React)
36-
* This is the ONLY place that reads from LRU outside of the hook.
37-
*
38-
* @returns The most recently used model, or WORKSPACE_DEFAULTS.model if LRU is empty
39-
*/
40-
export function getDefaultModelFromLRU(): string {
41-
const lru = readPersistedState<string[]>(LRU_KEY, DEFAULT_MODELS.slice(0, MAX_LRU_SIZE));
42-
return lru[0] ?? FALLBACK_MODEL;
36+
export function getDefaultModel(): string {
37+
const persisted = readPersistedState<string | null>(DEFAULT_MODEL_KEY, null);
38+
return persisted ?? FALLBACK_MODEL;
4339
}
4440

4541
/**
@@ -54,6 +50,12 @@ export function useModelLRU() {
5450
{ listener: true }
5551
);
5652

53+
const [defaultModel, setDefaultModel] = usePersistedState<string>(
54+
DEFAULT_MODEL_KEY,
55+
FALLBACK_MODEL,
56+
{ listener: true }
57+
);
58+
5759
// Merge any new defaults from MODEL_ABBREVIATIONS (only once on mount)
5860
useEffect(() => {
5961
setRecentModels((prev) => {
@@ -107,5 +109,7 @@ export function useModelLRU() {
107109
evictModel,
108110
getRecentModels,
109111
recentModels,
112+
defaultModel,
113+
setDefaultModel,
110114
};
111115
}

src/browser/hooks/useSendMessageOptions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useThinkingLevel } from "./useThinkingLevel";
22
import { useMode } from "@/browser/contexts/ModeContext";
33
import { usePersistedState } from "./usePersistedState";
4-
import { getDefaultModelFromLRU } from "./useModelLRU";
4+
import { getDefaultModel } from "./useModelLRU";
55
import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils";
66
import { getModelKey } from "@/common/constants/storage";
77
import type { SendMessageOptions } from "@/common/types/ipc";
@@ -56,7 +56,7 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptions {
5656
const [thinkingLevel] = useThinkingLevel();
5757
const [mode] = useMode();
5858
const { options: providerOptions } = useProviderOptions();
59-
const defaultModel = getDefaultModelFromLRU();
59+
const defaultModel = getDefaultModel();
6060
const [preferredModel] = usePersistedState<string>(
6161
getModelKey(workspaceId),
6262
defaultModel, // Default to most recently used model

src/browser/utils/messages/sendOptions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { getModelKey, getThinkingLevelKey, getModeKey } from "@/common/constants/storage";
22
import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils";
33
import { readPersistedState } from "@/browser/hooks/usePersistedState";
4-
import { getDefaultModelFromLRU } from "@/browser/hooks/useModelLRU";
4+
import { getDefaultModel } from "@/browser/hooks/useModelLRU";
55
import type { SendMessageOptions } from "@/common/types/ipc";
66
import type { UIMode } from "@/common/types/mode";
77
import type { ThinkingLevel } from "@/common/types/thinking";
@@ -38,7 +38,7 @@ function getProviderOptions(): MuxProviderOptions {
3838
*/
3939
export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptions {
4040
// Read model preference (workspace-specific), fallback to LRU default
41-
const model = readPersistedState<string>(getModelKey(workspaceId), getDefaultModelFromLRU());
41+
const model = readPersistedState<string>(getModelKey(workspaceId), getDefaultModel());
4242

4343
// Read thinking level (workspace-specific)
4444
const thinkingLevel = readPersistedState<ThinkingLevel>(

0 commit comments

Comments
 (0)