/ web / src / components / ModelPickerDialog.tsx
ModelPickerDialog.tsx
  1  import { Button } from "@nous-research/ui/ui/components/button";
  2  import { ListItem } from "@nous-research/ui/ui/components/list-item";
  3  import { Spinner } from "@nous-research/ui/ui/components/spinner";
  4  import { Input } from "@/components/ui/input";
  5  import type { GatewayClient } from "@/lib/gatewayClient";
  6  import { Check, Search, X } from "lucide-react";
  7  import { useEffect, useMemo, useRef, useState } from "react";
  8  
  9  /**
 10   * Two-stage model picker modal.
 11   *
 12   * Mirrors ui-tui/src/components/modelPicker.tsx:
 13   *   Stage 1: pick provider (authenticated providers only)
 14   *   Stage 2: pick model within that provider
 15   *
 16   * Two invocation modes:
 17   *
 18   * 1. Chat-session mode (ChatSidebar) — pass `gw` + `sessionId`. The picker
 19   *    loads options via `model.options` JSON-RPC and emits the result as a
 20   *    slash command string (`/model <model> --provider <slug> [--global]`)
 21   *    through `onSubmit`, which the ChatPage pipes to `slashExec`.
 22   *
 23   * 2. Standalone mode (ModelsPage, Config settings) — pass a `loader` and
 24   *    `onApply`. The picker fetches options via the REST endpoint and calls
 25   *    `onApply(provider, model, persistGlobal)` instead of emitting a slash
 26   *    command.  This lets the Models page reuse the same UI without
 27   *    requiring an open chat PTY.
 28   */
 29  
 30  interface ModelOptionProvider {
 31    name: string;
 32    slug: string;
 33    models?: string[];
 34    total_models?: number;
 35    is_current?: boolean;
 36    warning?: string;
 37  }
 38  
 39  interface ModelOptionsResponse {
 40    model?: string;
 41    provider?: string;
 42    providers?: ModelOptionProvider[];
 43  }
 44  
 45  interface Props {
 46    /** Chat-mode: when present, picker emits a slash command via onSubmit. */
 47    gw?: GatewayClient;
 48    sessionId?: string;
 49    onSubmit?(slashCommand: string): void;
 50  
 51    /** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */
 52    loader?(): Promise<ModelOptionsResponse>;
 53    onApply?(args: {
 54      provider: string;
 55      model: string;
 56      persistGlobal: boolean;
 57    }): Promise<void> | void;
 58  
 59    onClose(): void;
 60    title?: string;
 61    /** If true, hides "Persist globally" checkbox — always saves to config.yaml. */
 62    alwaysGlobal?: boolean;
 63  }
 64  
 65  export function ModelPickerDialog(props: Props) {
 66    const {
 67      gw,
 68      sessionId,
 69      onSubmit,
 70      loader,
 71      onApply,
 72      onClose,
 73      title = "Switch Model",
 74      alwaysGlobal = false,
 75    } = props;
 76    const standalone = !!loader && !!onApply;
 77  
 78    const [providers, setProviders] = useState<ModelOptionProvider[]>([]);
 79    const [currentModel, setCurrentModel] = useState("");
 80    const [currentProviderSlug, setCurrentProviderSlug] = useState("");
 81    const [loading, setLoading] = useState(true);
 82    const [error, setError] = useState<string | null>(null);
 83    const [selectedSlug, setSelectedSlug] = useState("");
 84    const [selectedModel, setSelectedModel] = useState("");
 85    const [query, setQuery] = useState("");
 86    const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal);
 87    const [applying, setApplying] = useState(false);
 88    const closedRef = useRef(false);
 89  
 90    // Load providers + models on open.
 91    useEffect(() => {
 92      closedRef.current = false;
 93  
 94      const promise = standalone
 95        ? (loader as () => Promise<ModelOptionsResponse>)()
 96        : (gw as GatewayClient).request<ModelOptionsResponse>(
 97            "model.options",
 98            sessionId ? { session_id: sessionId } : {},
 99          );
100  
101      promise
102        .then((r) => {
103          if (closedRef.current) return;
104          const next = r?.providers ?? [];
105          setProviders(next);
106          setCurrentModel(String(r?.model ?? ""));
107          setCurrentProviderSlug(String(r?.provider ?? ""));
108          setSelectedSlug(
109            (next.find((p) => p.is_current) ?? next[0])?.slug ?? "",
110          );
111          setSelectedModel("");
112          setLoading(false);
113        })
114        .catch((e) => {
115          if (closedRef.current) return;
116          setError(e instanceof Error ? e.message : String(e));
117          setLoading(false);
118        });
119  
120      return () => {
121        closedRef.current = true;
122      };
123      // Deliberately omit props from deps — stable for the dialog's lifetime.
124      // eslint-disable-next-line react-hooks/exhaustive-deps
125    }, []);
126  
127    // Esc closes.
128    useEffect(() => {
129      const onKey = (e: KeyboardEvent) => {
130        if (e.key === "Escape") {
131          e.preventDefault();
132          onClose();
133        }
134      };
135      window.addEventListener("keydown", onKey);
136      return () => window.removeEventListener("keydown", onKey);
137    }, [onClose]);
138  
139    const selectedProvider = useMemo(
140      () => providers.find((p) => p.slug === selectedSlug) ?? null,
141      [providers, selectedSlug],
142    );
143  
144    const models = useMemo(
145      () => selectedProvider?.models ?? [],
146      [selectedProvider],
147    );
148  
149    const needle = query.trim().toLowerCase();
150  
151    const filteredProviders = useMemo(
152      () =>
153        !needle
154          ? providers
155          : providers.filter(
156              (p) =>
157                p.name.toLowerCase().includes(needle) ||
158                p.slug.toLowerCase().includes(needle) ||
159                (p.models ?? []).some((m) => m.toLowerCase().includes(needle)),
160            ),
161      [providers, needle],
162    );
163  
164    const filteredModels = useMemo(
165      () =>
166        !needle ? models : models.filter((m) => m.toLowerCase().includes(needle)),
167      [models, needle],
168    );
169  
170    const canConfirm = !!selectedProvider && !!selectedModel && !applying;
171  
172    const confirm = async () => {
173      if (!canConfirm || !selectedProvider) return;
174      if (standalone && onApply) {
175        setApplying(true);
176        try {
177          await onApply({
178            provider: selectedProvider.slug,
179            model: selectedModel,
180            persistGlobal,
181          });
182          onClose();
183        } catch (e) {
184          setError(e instanceof Error ? e.message : String(e));
185        } finally {
186          setApplying(false);
187        }
188      } else if (onSubmit) {
189        const global = persistGlobal ? " --global" : "";
190        onSubmit(
191          `/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
192        );
193        onClose();
194      }
195    };
196  
197    return (
198      <div
199        className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
200        onClick={(e) => e.target === e.currentTarget && onClose()}
201        role="dialog"
202        aria-modal="true"
203        aria-labelledby="model-picker-title"
204      >
205        <div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
206          <Button
207            ghost
208            size="icon"
209            onClick={onClose}
210            className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
211            aria-label="Close"
212          >
213            <X />
214          </Button>
215  
216          <header className="p-5 pb-3 border-b border-border">
217            <h2
218              id="model-picker-title"
219              className="font-display text-base tracking-wider uppercase"
220            >
221              {title}
222            </h2>
223            <p className="text-xs text-muted-foreground mt-1 font-mono">
224              current: {currentModel || "(unknown)"}
225              {currentProviderSlug && ` · ${currentProviderSlug}`}
226            </p>
227          </header>
228  
229          <div className="px-5 pt-3 pb-2 border-b border-border">
230            <div className="relative">
231              <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
232              <Input
233                autoFocus
234                placeholder="Filter providers and models…"
235                value={query}
236                onChange={(e) => setQuery(e.target.value)}
237                className="pl-7 h-8 text-sm"
238              />
239            </div>
240          </div>
241  
242          <div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden">
243            <ProviderColumn
244              loading={loading}
245              error={error}
246              providers={filteredProviders}
247              total={providers.length}
248              selectedSlug={selectedSlug}
249              query={needle}
250              onSelect={(slug) => {
251                setSelectedSlug(slug);
252                setSelectedModel("");
253              }}
254            />
255  
256            <ModelColumn
257              provider={selectedProvider}
258              models={filteredModels}
259              allModels={models}
260              selectedModel={selectedModel}
261              currentModel={currentModel}
262              currentProviderSlug={currentProviderSlug}
263              onSelect={setSelectedModel}
264              onConfirm={(m) => {
265                setSelectedModel(m);
266                // Confirm on next tick so state settles.
267                window.setTimeout(confirm, 0);
268              }}
269            />
270          </div>
271  
272          <footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
273            {alwaysGlobal ? (
274              <span className="text-xs text-muted-foreground">
275                Saves to config.yaml — applies to new sessions.
276              </span>
277            ) : (
278              <label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
279                <input
280                  type="checkbox"
281                  checked={persistGlobal}
282                  onChange={(e) => setPersistGlobal(e.target.checked)}
283                  className="cursor-pointer"
284                />
285                Persist globally (otherwise this session only)
286              </label>
287            )}
288  
289            <div className="flex items-center gap-2 ml-auto">
290              <Button outlined onClick={onClose} disabled={applying}>
291                Cancel
292              </Button>
293              <Button onClick={confirm} disabled={!canConfirm}>
294                {applying ? <Spinner /> : "Switch"}
295              </Button>
296            </div>
297          </footer>
298        </div>
299      </div>
300    );
301  }
302  
303  /* ------------------------------------------------------------------ */
304  /*  Provider column                                                    */
305  /* ------------------------------------------------------------------ */
306  
307  function ProviderColumn({
308    loading,
309    error,
310    providers,
311    total,
312    selectedSlug,
313    query,
314    onSelect,
315  }: {
316    loading: boolean;
317    error: string | null;
318    providers: ModelOptionProvider[];
319    total: number;
320    selectedSlug: string;
321    query: string;
322    onSelect(slug: string): void;
323  }) {
324    return (
325      <div className="border-r border-border overflow-y-auto">
326        {loading && (
327          <div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
328            <Spinner className="text-xs" /> loading…
329          </div>
330        )}
331  
332        {error && <div className="p-4 text-xs text-destructive">{error}</div>}
333  
334        {!loading && !error && providers.length === 0 && (
335          <div className="p-4 text-xs text-muted-foreground italic">
336            {query
337              ? "no matches"
338              : total === 0
339                ? "no authenticated providers"
340                : "no matches"}
341          </div>
342        )}
343  
344        {providers.map((p) => {
345          const active = p.slug === selectedSlug;
346          return (
347            <ListItem
348              key={p.slug}
349              active={active}
350              onClick={() => onSelect(p.slug)}
351              className={`items-start text-xs border-l-2 ${
352                active ? "border-l-primary" : "border-l-transparent"
353              }`}
354            >
355              <div className="flex-1 min-w-0">
356                <div className="flex items-center gap-1.5">
357                  <span className="font-medium truncate">{p.name}</span>
358                  {p.is_current && <CurrentTag />}
359                </div>
360                <div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate">
361                  {p.slug} · {p.total_models ?? p.models?.length ?? 0} models
362                </div>
363              </div>
364            </ListItem>
365          );
366        })}
367      </div>
368    );
369  }
370  
371  /* ------------------------------------------------------------------ */
372  /*  Model column                                                       */
373  /* ------------------------------------------------------------------ */
374  
375  function ModelColumn({
376    provider,
377    models,
378    allModels,
379    selectedModel,
380    currentModel,
381    currentProviderSlug,
382    onSelect,
383    onConfirm,
384  }: {
385    provider: ModelOptionProvider | null;
386    models: string[];
387    allModels: string[];
388    selectedModel: string;
389    currentModel: string;
390    currentProviderSlug: string;
391    onSelect(model: string): void;
392    onConfirm(model: string): void;
393  }) {
394    if (!provider) {
395      return (
396        <div className="overflow-y-auto">
397          <div className="p-4 text-xs text-muted-foreground italic">
398            pick a provider →
399          </div>
400        </div>
401      );
402    }
403  
404    return (
405      <div className="overflow-y-auto">
406        {provider.warning && (
407          <div className="p-3 text-xs text-destructive border-b border-border">
408            {provider.warning}
409          </div>
410        )}
411  
412        {models.length === 0 ? (
413          <div className="p-4 text-xs text-muted-foreground italic">
414            {allModels.length
415              ? "no models match your filter"
416              : "no models listed for this provider"}
417          </div>
418        ) : (
419          models.map((m) => {
420            const active = m === selectedModel;
421            const isCurrent =
422              m === currentModel && provider.slug === currentProviderSlug;
423  
424            return (
425              <ListItem
426                key={m}
427                active={active}
428                onClick={() => onSelect(m)}
429                onDoubleClick={() => onConfirm(m)}
430                className="px-3 py-1.5 text-xs font-mono"
431              >
432                <Check
433                  className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
434                />
435                <span className="flex-1 truncate">{m}</span>
436                {isCurrent && <CurrentTag />}
437              </ListItem>
438            );
439          })
440        )}
441      </div>
442    );
443  }
444  
445  function CurrentTag() {
446    return (
447      <span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0">
448        current
449      </span>
450    );
451  }