Skip to content

Commit 5aa8603

Browse files
committed
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
1 parent a7808d3 commit 5aa8603

File tree

4 files changed

+95
-18
lines changed

4 files changed

+95
-18
lines changed

src/browser/components/ChatInput/index.tsx

Lines changed: 3 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, {
@@ -967,6 +967,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
967967
recentModels={recentModels}
968968
onRemoveModel={evictModel}
969969
onComplete={() => inputRef.current?.focus()}
970+
defaultModel={defaultModel}
971+
onSetDefaultModel={setDefaultModel}
970972
/>
971973
<TooltipWrapper inline>
972974
<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: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,27 @@ import React, {
77
forwardRef,
88
} from "react";
99
import { cn } from "@/common/lib/utils";
10+
import { Star } from "lucide-react";
1011

1112
interface ModelSelectorProps {
1213
value: string;
1314
onChange: (value: string) => void;
1415
recentModels: string[];
1516
onRemoveModel?: (model: string) => void;
1617
onComplete?: () => void;
18+
defaultModel?: string | null;
19+
onSetDefaultModel?: (model: string | null) => void;
1720
}
1821

1922
export interface ModelSelectorRef {
2023
open: () => void;
2124
}
2225

2326
export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
24-
({ value, onChange, recentModels, onRemoveModel, onComplete }, ref) => {
27+
(
28+
{ value, onChange, recentModels, onRemoveModel, onComplete, defaultModel, onSetDefaultModel },
29+
ref
30+
) => {
2531
const [isEditing, setIsEditing] = useState(false);
2632
const [inputValue, setInputValue] = useState(value);
2733
const [error, setError] = useState<string | null>(null);
@@ -243,16 +249,44 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
243249
>
244250
<div className="flex items-center justify-between gap-2">
245251
<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-
)}
252+
<div className="flex items-center gap-1">
253+
{onSetDefaultModel && (
254+
<button
255+
type="button"
256+
onClick={(event) => {
257+
event.preventDefault();
258+
event.stopPropagation();
259+
const isDefault = defaultModel === model;
260+
// If it's already the default, do nothing (cannot unset)
261+
if (!isDefault) {
262+
onSetDefaultModel(model);
263+
}
264+
}}
265+
className={cn(
266+
"rounded-sm border px-1 py-0.5 transition-colors duration-150 flex items-center justify-center",
267+
defaultModel === model
268+
? "text-yellow-400 border-yellow-400/40 cursor-default"
269+
: "text-muted-light border-border-light/40 hover:border-foreground/60 hover:text-foreground"
270+
)}
271+
aria-label={
272+
defaultModel === model ? "Current default model" : "Set as default model"
273+
}
274+
disabled={defaultModel === model}
275+
>
276+
<Star className={cn("h-3 w-3", defaultModel === model && "fill-current")} />
277+
</button>
278+
)}
279+
{onRemoveModel && defaultModel !== model && (
280+
<button
281+
type="button"
282+
onClick={(event) => handleRemoveModel(model, event)}
283+
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"
284+
aria-label={`Remove ${model} from recent models`}
285+
>
286+
×
287+
</button>
288+
)}
289+
</div>
256290
</div>
257291
</div>
258292
))}

src/browser/hooks/useModelLRU.ts

Lines changed: 32 additions & 6 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,19 @@ export function evictModelFromLRU(model: string): void {
3133
persistModels(nextList);
3234
}
3335

36+
export function getExplicitDefaultModel(): string {
37+
const persisted = readPersistedState<string | null>(DEFAULT_MODEL_KEY, null);
38+
return persisted ?? FALLBACK_MODEL;
39+
}
40+
3441
/**
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
42+
* Get the default model.
43+
* Prioritizes the explicit default model if set.
44+
* Otherwise, falls back to LRU behavior (most recently used).
3945
*/
4046
export function getDefaultModelFromLRU(): string {
41-
const lru = readPersistedState<string[]>(LRU_KEY, DEFAULT_MODELS.slice(0, MAX_LRU_SIZE));
42-
return lru[0] ?? FALLBACK_MODEL;
47+
// Always return the explicit default (which falls back to hardcoded default)
48+
return getExplicitDefaultModel();
4349
}
4450

4551
/**
@@ -54,6 +60,24 @@ export function useModelLRU() {
5460
{ listener: true }
5561
);
5662

63+
const [defaultModel, setDefaultModel] = usePersistedState<string | null>(
64+
DEFAULT_MODEL_KEY,
65+
null,
66+
{ listener: true }
67+
);
68+
69+
// Return the effective default model (never null)
70+
const effectiveDefaultModel = defaultModel ?? FALLBACK_MODEL;
71+
72+
// Wrapper for setDefaultModel that prevents null/clearing
73+
const handleSetDefaultModel = useCallback(
74+
(model: string | null) => {
75+
if (!model) return;
76+
setDefaultModel(model);
77+
},
78+
[setDefaultModel]
79+
);
80+
5781
// Merge any new defaults from MODEL_ABBREVIATIONS (only once on mount)
5882
useEffect(() => {
5983
setRecentModels((prev) => {
@@ -107,5 +131,7 @@ export function useModelLRU() {
107131
evictModel,
108132
getRecentModels,
109133
recentModels,
134+
defaultModel: effectiveDefaultModel,
135+
setDefaultModel: handleSetDefaultModel,
110136
};
111137
}

0 commit comments

Comments
 (0)