Skip to content

Commit 6f906a3

Browse files
authored
🤖 fix: evict stale/bad models from ModelSelector (#593)
## Summary - add an "×" control in the ModelSelector dropdown so users can remove a recent model and keep the LRU in sync - watch for `stream-error` messages with `errorType === "model_not_found"` and proactively evict the dead model from the LRU ## Testing - make typecheck _Generated with `mux`_
1 parent 395daba commit 6f906a3

File tree

5 files changed

+96
-6
lines changed

5 files changed

+96
-6
lines changed

src/components/AIView.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { getModelName } from "@/utils/ai/models";
2626
import type { DisplayedMessage } from "@/types/message";
2727
import type { RuntimeConfig } from "@/types/runtime";
2828
import { useAIViewKeybinds } from "@/hooks/useAIViewKeybinds";
29+
import { evictModelFromLRU } from "@/hooks/useModelLRU";
2930

3031
interface AIViewProps {
3132
workspaceId: string;
@@ -66,9 +67,35 @@ const AIViewInner: React.FC<AIViewProps> = ({
6667
storageKey: "review-sidebar-width", // Persists across sessions
6768
});
6869

69-
// Get workspace state from store (only re-renders when THIS workspace changes)
7070
const workspaceState = useWorkspaceState(workspaceId);
7171
const aggregator = useWorkspaceAggregator(workspaceId);
72+
const handledModelErrorsRef = useRef<Set<string>>(new Set());
73+
74+
useEffect(() => {
75+
handledModelErrorsRef.current.clear();
76+
}, [workspaceId]);
77+
78+
useEffect(() => {
79+
if (!workspaceState) {
80+
return;
81+
}
82+
83+
for (const message of workspaceState.messages) {
84+
if (message.type !== "stream-error") {
85+
continue;
86+
}
87+
if (message.errorType !== "model_not_found") {
88+
continue;
89+
}
90+
if (handledModelErrorsRef.current.has(message.id)) {
91+
continue;
92+
}
93+
handledModelErrorsRef.current.add(message.id);
94+
if (message.model) {
95+
evictModelFromLRU(message.model);
96+
}
97+
}
98+
}, [workspaceState, workspaceId]);
7299

73100
const [editingMessage, setEditingMessage] = useState<{ id: string; content: string } | undefined>(
74101
undefined

src/components/ChatInput/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
129129
const inputRef = useRef<HTMLTextAreaElement>(null);
130130
const modelSelectorRef = useRef<ModelSelectorRef>(null);
131131
const [mode, setMode] = useMode();
132-
const { recentModels, addModel } = useModelLRU();
132+
const { recentModels, addModel, evictModel } = useModelLRU();
133133
const commandListId = useId();
134134
const telemetry = useTelemetry();
135135
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
@@ -922,6 +922,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
922922
value={preferredModel}
923923
onChange={setPreferredModel}
924924
recentModels={recentModels}
925+
onRemoveModel={evictModel}
925926
onComplete={() => inputRef.current?.focus()}
926927
/>
927928
<TooltipWrapper inline>

src/components/ModelSelector.stories.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ const meta = {
1818
control: false,
1919
description: "Callback when model changes",
2020
},
21+
onRemoveModel: {
22+
control: false,
23+
description: "Callback when a model is removed",
24+
},
2125
recentModels: {
2226
control: { type: "object" },
2327
description: "List of recently used models",
@@ -36,6 +40,7 @@ export const Default: Story = {
3640
args: {
3741
value: "anthropic:claude-sonnet-4-5",
3842
onChange: action("onChange"),
43+
onRemoveModel: action("onRemoveModel"),
3944
recentModels: ["anthropic:claude-sonnet-4-5", "anthropic:claude-opus-4-1", "openai:gpt-5-pro"],
4045
onComplete: action("onComplete"),
4146
},
@@ -45,6 +50,7 @@ export const LongModelName: Story = {
4550
args: {
4651
value: "anthropic:claude-opus-4-20250514-preview-experimental",
4752
onChange: action("onChange"),
53+
onRemoveModel: action("onRemoveModel"),
4854
recentModels: [
4955
"anthropic:claude-opus-4-20250514-preview-experimental",
5056
"anthropic:claude-sonnet-4-20250514-preview-experimental",
@@ -58,6 +64,7 @@ export const WithManyModels: Story = {
5864
args: {
5965
value: "anthropic:claude-sonnet-4-5",
6066
onChange: action("onChange"),
67+
onRemoveModel: action("onRemoveModel"),
6168
recentModels: [
6269
"anthropic:claude-sonnet-4-5",
6370
"anthropic:claude-opus-4-1",

src/components/ModelSelector.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface ModelSelectorProps {
1212
value: string;
1313
onChange: (value: string) => void;
1414
recentModels: string[];
15+
onRemoveModel?: (model: string) => void;
1516
onComplete?: () => void;
1617
}
1718

@@ -20,7 +21,7 @@ export interface ModelSelectorRef {
2021
}
2122

2223
export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
23-
({ value, onChange, recentModels, onComplete }, ref) => {
24+
({ value, onChange, recentModels, onRemoveModel, onComplete }, ref) => {
2425
const [isEditing, setIsEditing] = useState(false);
2526
const [inputValue, setInputValue] = useState(value);
2627
const [error, setError] = useState<string | null>(null);
@@ -151,6 +152,22 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
151152
setShowDropdown(false);
152153
};
153154

155+
const handleRemoveModel = useCallback(
156+
(model: string, event: React.MouseEvent<HTMLButtonElement>) => {
157+
event.preventDefault();
158+
event.stopPropagation();
159+
if (!onRemoveModel) {
160+
return;
161+
}
162+
onRemoveModel(model);
163+
setHighlightedIndex(-1);
164+
if (inputValue === model) {
165+
setInputValue("");
166+
}
167+
},
168+
[inputValue, onRemoveModel]
169+
);
170+
154171
const handleClick = useCallback(() => {
155172
setIsEditing(true);
156173
setInputValue(""); // Clear input to show all models
@@ -224,7 +241,19 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
224241
)}
225242
onClick={() => handleSelectModel(model)}
226243
>
227-
{model}
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+
)}
256+
</div>
228257
</div>
229258
))}
230259
</div>

src/hooks/useModelLRU.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useEffect } from "react";
2-
import { usePersistedState, readPersistedState } from "./usePersistedState";
2+
import { usePersistedState, readPersistedState, updatePersistedState } from "./usePersistedState";
33
import { MODEL_ABBREVIATIONS } from "@/utils/slashCommands/registry";
44
import { defaultModel } from "@/utils/ai/models";
55

@@ -12,6 +12,23 @@ const DEFAULT_MODELS = [
1212
defaultModel,
1313
...Object.values(MODEL_ABBREVIATIONS).filter((m) => m !== defaultModel),
1414
].slice(0, MAX_LRU_SIZE);
15+
function persistModels(models: string[]): void {
16+
updatePersistedState(LRU_KEY, models.slice(0, MAX_LRU_SIZE));
17+
}
18+
19+
export function evictModelFromLRU(model: string): void {
20+
const normalized = model.trim();
21+
if (!normalized) {
22+
return;
23+
}
24+
const current = readPersistedState<string[]>(LRU_KEY, DEFAULT_MODELS.slice(0, MAX_LRU_SIZE));
25+
const filtered = current.filter((m) => m !== normalized);
26+
if (filtered.length === current.length) {
27+
return;
28+
}
29+
const nextList = filtered.length > 0 ? filtered : DEFAULT_MODELS.slice(0, MAX_LRU_SIZE);
30+
persistModels(nextList);
31+
}
1532

1633
/**
1734
* Get the default model from LRU (non-hook version for use outside React)
@@ -32,7 +49,8 @@ export function getDefaultModelFromLRU(): string {
3249
export function useModelLRU() {
3350
const [recentModels, setRecentModels] = usePersistedState<string[]>(
3451
LRU_KEY,
35-
DEFAULT_MODELS.slice(0, MAX_LRU_SIZE)
52+
DEFAULT_MODELS.slice(0, MAX_LRU_SIZE),
53+
{ listener: true }
3654
);
3755

3856
// Merge any new defaults from MODEL_ABBREVIATIONS (only once on mount)
@@ -76,8 +94,16 @@ export function useModelLRU() {
7694
return recentModels;
7795
}, [recentModels]);
7896

97+
const evictModel = useCallback((modelString: string) => {
98+
if (!modelString.trim()) {
99+
return;
100+
}
101+
evictModelFromLRU(modelString);
102+
}, []);
103+
79104
return {
80105
addModel,
106+
evictModel,
81107
getRecentModels,
82108
recentModels,
83109
};

0 commit comments

Comments
 (0)