Skip to content

Commit 9865f82

Browse files
voidstackloopclaude
andcommitted
Replace the model selector with a searchable LM Studio-style picker dialog
The inline grouped <Select> (Ollama/llama.cpp/ROCm/ChatGPT/Claude/Gemini/ custom, all in one long native-style dropdown) is now a button that opens a modal: a search box filters across every group live, each entry renders as a row with its real installed size where one exists (Ollama/llama.cpp/ROCm report actual bytes on disk; cloud/custom models have none to show), and the current selection is check-marked. Empty groups (e.g. Ollama with nothing pulled yet) are dropped rather than shown as a dead-end heading. The custom-model-ID entry flow (one per cloud provider) is preserved exactly, just rendered as a step inside the dialog instead of an inline input in the toolbar. No changes to model resolution, session persistence, or the underlying provider/model-ref logic — this only touches how a model is picked. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent b65fb82 commit 9865f82

2 files changed

Lines changed: 313 additions & 118 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { useMemo, useState } from "react";
2+
import { Check, Search } from "lucide-react";
3+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
4+
import { Input } from "@/components/ui/input";
5+
import { Button } from "@/components/ui/button";
6+
import { ScrollArea } from "@/components/ui/scroll-area";
7+
import { cn } from "@/lib/utils";
8+
import type { ProviderId } from "@/types/electron";
9+
10+
export interface ModelPickerItem {
11+
ref: string;
12+
name: string;
13+
// Bytes on disk — known for local models (Ollama/llama.cpp/ROCm), absent
14+
// for cloud/custom ones since there's nothing local to size.
15+
sizeBytes?: number;
16+
// Marks the "Custom model ID..." row for a cloud provider — selecting it
17+
// doesn't pick a model directly, it opens the id-entry step below.
18+
customSentinelProvider?: ProviderId;
19+
}
20+
21+
export interface ModelPickerGroup {
22+
key: string;
23+
label: string;
24+
items: ModelPickerItem[];
25+
}
26+
27+
export interface ModelPickerDialogProps {
28+
open: boolean;
29+
onOpenChange: (open: boolean) => void;
30+
currentModel: string;
31+
groups: ModelPickerGroup[];
32+
onSelectModel: (ref: string) => void;
33+
// Custom-model-id entry step (one cloud provider's "Custom model ID..."
34+
// row was picked) — owned by the caller since it's the same state Chat.tsx
35+
// already tracks for other reasons (e.g. surviving a session reload).
36+
pendingCustomProvider: ProviderId | null;
37+
customModelInput: string;
38+
onCustomModelInputChange: (value: string) => void;
39+
onConfirmCustomModel: () => void;
40+
onCancelCustomProvider: () => void;
41+
providerLabel: (provider: ProviderId) => string;
42+
}
43+
44+
function formatSize(bytes?: number): string | null {
45+
if (!bytes || bytes <= 0) return null;
46+
const units = ["B", "KB", "MB", "GB", "TB"];
47+
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
48+
return `${(bytes / 1024 ** index).toFixed(index > 1 ? 1 : 0)} ${units[index]}`;
49+
}
50+
51+
export function ModelPickerDialog({
52+
open,
53+
onOpenChange,
54+
currentModel,
55+
groups,
56+
onSelectModel,
57+
pendingCustomProvider,
58+
customModelInput,
59+
onCustomModelInputChange,
60+
onConfirmCustomModel,
61+
onCancelCustomProvider,
62+
providerLabel,
63+
}: ModelPickerDialogProps) {
64+
const [query, setQuery] = useState("");
65+
66+
const filteredGroups = useMemo(() => {
67+
const q = query.trim().toLowerCase();
68+
return groups
69+
.map((group) =>
70+
q
71+
? {
72+
...group,
73+
items: group.items.filter(
74+
(item) => item.name.toLowerCase().includes(q) || group.label.toLowerCase().includes(q)
75+
),
76+
}
77+
: group
78+
)
79+
// An empty group (e.g. "Ollama (local)" with nothing installed
80+
// yet) reads as a dead-end heading with nothing under it —
81+
// dropped unconditionally, not just while a search is active.
82+
.filter((group) => group.items.length > 0);
83+
}, [groups, query]);
84+
85+
function handleOpenChange(next: boolean) {
86+
if (!next) {
87+
setQuery("");
88+
onCancelCustomProvider();
89+
}
90+
onOpenChange(next);
91+
}
92+
93+
function selectItem(item: ModelPickerItem) {
94+
onSelectModel(item.ref);
95+
if (!item.customSentinelProvider) {
96+
setQuery("");
97+
onOpenChange(false);
98+
}
99+
}
100+
101+
function confirmCustom() {
102+
onConfirmCustomModel();
103+
setQuery("");
104+
onOpenChange(false);
105+
}
106+
107+
return (
108+
<Dialog open={open} onOpenChange={handleOpenChange}>
109+
<DialogContent className="flex max-h-[80vh] max-w-xl flex-col gap-3 p-0 sm:max-w-xl">
110+
<DialogHeader className="px-4 pt-4">
111+
<DialogTitle>{pendingCustomProvider ? "Enter a model ID" : "Select a model"}</DialogTitle>
112+
</DialogHeader>
113+
114+
{pendingCustomProvider ? (
115+
<div className="flex flex-col gap-3 px-4 pb-4">
116+
<p className="text-xs text-muted-foreground">
117+
The exact model ID {providerLabel(pendingCustomProvider)} expects — this isn't validated
118+
against a catalog, so a typo will only surface as a failed request.
119+
</p>
120+
<Input
121+
autoFocus
122+
value={customModelInput}
123+
onChange={(e) => onCustomModelInputChange(e.target.value)}
124+
onKeyDown={(e) => e.key === "Enter" && confirmCustom()}
125+
placeholder="exact model id..."
126+
/>
127+
<div className="flex justify-end gap-2">
128+
<Button size="sm" variant="outline" onClick={onCancelCustomProvider}>
129+
Back
130+
</Button>
131+
<Button size="sm" onClick={confirmCustom} disabled={!customModelInput.trim()}>
132+
Use this model
133+
</Button>
134+
</div>
135+
</div>
136+
) : (
137+
<>
138+
<div className="relative px-4">
139+
<Search className="pointer-events-none absolute left-6 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
140+
<Input
141+
autoFocus
142+
value={query}
143+
onChange={(e) => setQuery(e.target.value)}
144+
placeholder="Search models..."
145+
aria-label="Search models"
146+
className="pl-8"
147+
/>
148+
</div>
149+
<ScrollArea className="min-h-0 flex-1 px-4 pb-4">
150+
<div className="flex flex-col gap-4">
151+
{filteredGroups.length === 0 && (
152+
<p className="py-8 text-center text-sm text-muted-foreground">
153+
No models match &ldquo;{query}&rdquo;.
154+
</p>
155+
)}
156+
{filteredGroups.map((group) => (
157+
<div key={group.key} className="flex flex-col gap-1">
158+
<p className="section-eyebrow px-1">{group.label}</p>
159+
<div className="flex flex-col gap-0.5">
160+
{group.items.map((item) => {
161+
const size = formatSize(item.sizeBytes);
162+
const active = item.ref === currentModel;
163+
return (
164+
<button
165+
key={item.ref}
166+
onClick={() => selectItem(item)}
167+
className={cn(
168+
"flex items-center justify-between gap-3 rounded-lg border border-transparent px-3 py-2 text-left text-sm transition-colors hover:bg-muted",
169+
active && "border-primary/25 bg-primary/8"
170+
)}
171+
>
172+
<span className="min-w-0 flex-1 truncate font-medium">
173+
{item.name}
174+
</span>
175+
{size && (
176+
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
177+
{size}
178+
</span>
179+
)}
180+
{active && <Check className="size-3.5 shrink-0 text-primary" />}
181+
</button>
182+
);
183+
})}
184+
</div>
185+
</div>
186+
))}
187+
</div>
188+
</ScrollArea>
189+
</>
190+
)}
191+
</DialogContent>
192+
</Dialog>
193+
);
194+
}

0 commit comments

Comments
 (0)