From 8b77f52fdd3e8a77dc68132c1765f6c26bff6a5e Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 14 Nov 2025 17:00:24 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20evict=20missing=20mod?= =?UTF-8?q?els?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AIView.tsx | 29 ++++++++++++++++++++- src/components/ChatInput/index.tsx | 3 ++- src/components/ModelSelector.stories.tsx | 7 +++++ src/components/ModelSelector.tsx | 33 ++++++++++++++++++++++-- src/hooks/useModelLRU.ts | 30 +++++++++++++++++++-- 5 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index c0740836b9..37870af8d1 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -26,6 +26,7 @@ import { getModelName } from "@/utils/ai/models"; import type { DisplayedMessage } from "@/types/message"; import type { RuntimeConfig } from "@/types/runtime"; import { useAIViewKeybinds } from "@/hooks/useAIViewKeybinds"; +import { evictModelFromLRU } from "@/hooks/useModelLRU"; interface AIViewProps { workspaceId: string; @@ -66,9 +67,35 @@ const AIViewInner: React.FC = ({ storageKey: "review-sidebar-width", // Persists across sessions }); - // Get workspace state from store (only re-renders when THIS workspace changes) const workspaceState = useWorkspaceState(workspaceId); const aggregator = useWorkspaceAggregator(workspaceId); + const handledModelErrorsRef = useRef>(new Set()); + + useEffect(() => { + handledModelErrorsRef.current.clear(); + }, [workspaceId]); + + useEffect(() => { + if (!workspaceState) { + return; + } + + for (const message of workspaceState.messages) { + if (message.type !== "stream-error") { + continue; + } + if (message.errorType !== "model_not_found") { + continue; + } + if (handledModelErrorsRef.current.has(message.id)) { + continue; + } + handledModelErrorsRef.current.add(message.id); + if (message.model) { + evictModelFromLRU(message.model); + } + } + }, [workspaceState, workspaceId]); const [editingMessage, setEditingMessage] = useState<{ id: string; content: string } | undefined>( undefined diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx index af2cf8105b..814630b884 100644 --- a/src/components/ChatInput/index.tsx +++ b/src/components/ChatInput/index.tsx @@ -129,7 +129,7 @@ export const ChatInput: React.FC = (props) => { const inputRef = useRef(null); const modelSelectorRef = useRef(null); const [mode, setMode] = useMode(); - const { recentModels, addModel } = useModelLRU(); + const { recentModels, addModel, evictModel } = useModelLRU(); const commandListId = useId(); const telemetry = useTelemetry(); const [vimEnabled, setVimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { @@ -922,6 +922,7 @@ export const ChatInput: React.FC = (props) => { value={preferredModel} onChange={setPreferredModel} recentModels={recentModels} + onRemoveModel={evictModel} onComplete={() => inputRef.current?.focus()} /> diff --git a/src/components/ModelSelector.stories.tsx b/src/components/ModelSelector.stories.tsx index fd4d2f9621..201c7efffe 100644 --- a/src/components/ModelSelector.stories.tsx +++ b/src/components/ModelSelector.stories.tsx @@ -18,6 +18,10 @@ const meta = { control: false, description: "Callback when model changes", }, + onRemoveModel: { + control: false, + description: "Callback when a model is removed", + }, recentModels: { control: { type: "object" }, description: "List of recently used models", @@ -36,6 +40,7 @@ export const Default: 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"), }, @@ -45,6 +50,7 @@ export const LongModelName: Story = { args: { value: "anthropic:claude-opus-4-20250514-preview-experimental", onChange: action("onChange"), + onRemoveModel: action("onRemoveModel"), recentModels: [ "anthropic:claude-opus-4-20250514-preview-experimental", "anthropic:claude-sonnet-4-20250514-preview-experimental", @@ -58,6 +64,7 @@ export const WithManyModels: 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", diff --git a/src/components/ModelSelector.tsx b/src/components/ModelSelector.tsx index 64d7298369..b552eccdc3 100644 --- a/src/components/ModelSelector.tsx +++ b/src/components/ModelSelector.tsx @@ -12,6 +12,7 @@ interface ModelSelectorProps { value: string; onChange: (value: string) => void; recentModels: string[]; + onRemoveModel?: (model: string) => void; onComplete?: () => void; } @@ -20,7 +21,7 @@ export interface ModelSelectorRef { } export const ModelSelector = forwardRef( - ({ value, onChange, recentModels, onComplete }, ref) => { + ({ value, onChange, recentModels, onRemoveModel, onComplete }, ref) => { const [isEditing, setIsEditing] = useState(false); const [inputValue, setInputValue] = useState(value); const [error, setError] = useState(null); @@ -151,6 +152,22 @@ export const ModelSelector = forwardRef( setShowDropdown(false); }; + const handleRemoveModel = useCallback( + (model: string, event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!onRemoveModel) { + return; + } + onRemoveModel(model); + setHighlightedIndex(-1); + if (inputValue === model) { + setInputValue(""); + } + }, + [inputValue, onRemoveModel] + ); + const handleClick = useCallback(() => { setIsEditing(true); setInputValue(""); // Clear input to show all models @@ -224,7 +241,19 @@ export const ModelSelector = forwardRef( )} onClick={() => handleSelectModel(model)} > - {model} +
+ {model} + {onRemoveModel && ( + + )} +
))} diff --git a/src/hooks/useModelLRU.ts b/src/hooks/useModelLRU.ts index 47947e5162..8dbbafaa88 100644 --- a/src/hooks/useModelLRU.ts +++ b/src/hooks/useModelLRU.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect } from "react"; -import { usePersistedState, readPersistedState } from "./usePersistedState"; +import { usePersistedState, readPersistedState, updatePersistedState } from "./usePersistedState"; import { MODEL_ABBREVIATIONS } from "@/utils/slashCommands/registry"; import { defaultModel } from "@/utils/ai/models"; @@ -12,6 +12,23 @@ const DEFAULT_MODELS = [ defaultModel, ...Object.values(MODEL_ABBREVIATIONS).filter((m) => m !== defaultModel), ].slice(0, MAX_LRU_SIZE); +function persistModels(models: string[]): void { + updatePersistedState(LRU_KEY, models.slice(0, MAX_LRU_SIZE)); +} + +export function evictModelFromLRU(model: string): void { + const normalized = model.trim(); + if (!normalized) { + return; + } + const current = readPersistedState(LRU_KEY, DEFAULT_MODELS.slice(0, MAX_LRU_SIZE)); + const filtered = current.filter((m) => m !== normalized); + if (filtered.length === current.length) { + return; + } + const nextList = filtered.length > 0 ? filtered : DEFAULT_MODELS.slice(0, MAX_LRU_SIZE); + persistModels(nextList); +} /** * Get the default model from LRU (non-hook version for use outside React) @@ -32,7 +49,8 @@ export function getDefaultModelFromLRU(): string { export function useModelLRU() { const [recentModels, setRecentModels] = usePersistedState( LRU_KEY, - DEFAULT_MODELS.slice(0, MAX_LRU_SIZE) + DEFAULT_MODELS.slice(0, MAX_LRU_SIZE), + { listener: true } ); // Merge any new defaults from MODEL_ABBREVIATIONS (only once on mount) @@ -76,8 +94,16 @@ export function useModelLRU() { return recentModels; }, [recentModels]); + const evictModel = useCallback((modelString: string) => { + if (!modelString.trim()) { + return; + } + evictModelFromLRU(modelString); + }, []); + return { addModel, + evictModel, getRecentModels, recentModels, }; From 9a319cc2c6845b3c54d8f4c6397d7d8d31f854a4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 14 Nov 2025 18:04:27 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20reorder=20Tailwind=20?= =?UTF-8?q?classnames=20in=20ModelSelector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ModelSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ModelSelector.tsx b/src/components/ModelSelector.tsx index b552eccdc3..95f8a1ba8d 100644 --- a/src/components/ModelSelector.tsx +++ b/src/components/ModelSelector.tsx @@ -247,7 +247,7 @@ export const ModelSelector = forwardRef(