Something went wrong. Try again.
The agent that grows with you
Something went wrong. Try again.
12 kB · 392 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import type { GatewayClient } from "@/lib/gatewayClient";import { Check, Loader2, Search, X } from "lucide-react";import { useEffect, useMemo, useRef, useState } from "react";
/** * Two-stage model picker modal. * * Mirrors ui-tui/src/components/modelPicker.tsx: * Stage 1: pick provider (authenticated providers only) * Stage 2: pick model within that provider * * On confirm, emits `/model <model> --provider <slug> [--global]` through * the parent callback so ChatPage can dispatch it via the existing slash * pipeline. That keeps persistence + actual switch logic in one place. */
interface ModelOptionProvider { name: string; slug: string; models?: string[]; total_models?: number; is_current?: boolean; warning?: string;}
interface ModelOptionsResponse { model?: string; provider?: string; providers?: ModelOptionProvider[];}
interface Props { gw: GatewayClient; sessionId: string; onClose(): void; /** Parent runs the resulting slash command through slashExec. */ onSubmit(slashCommand: string): void;}
export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) { const [providers, setProviders] = useState<ModelOptionProvider[]>([]); const [currentModel, setCurrentModel] = useState(""); const [currentProviderSlug, setCurrentProviderSlug] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const [selectedSlug, setSelectedSlug] = useState(""); const [selectedModel, setSelectedModel] = useState(""); const [query, setQuery] = useState(""); const [persistGlobal, setPersistGlobal] = useState(false); const closedRef = useRef(false);
// Load providers + models on open. useEffect(() => { closedRef.current = false;
gw.request<ModelOptionsResponse>( "model.options", sessionId ? { session_id: sessionId } : {}, ) .then((r) => { if (closedRef.current) return; const next = r?.providers ?? []; setProviders(next); setCurrentModel(String(r?.model ?? "")); setCurrentProviderSlug(String(r?.provider ?? "")); setSelectedSlug( (next.find((p) => p.is_current) ?? next[0])?.slug ?? "", ); setSelectedModel(""); setLoading(false); }) .catch((e) => { if (closedRef.current) return; setError(e instanceof Error ? e.message : String(e)); setLoading(false); });
return () => { closedRef.current = true; }; }, [gw, sessionId]);
// Esc closes. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onClose(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]);
const selectedProvider = useMemo( () => providers.find((p) => p.slug === selectedSlug) ?? null, [providers, selectedSlug], );
const models = useMemo( () => selectedProvider?.models ?? [], [selectedProvider], );
const needle = query.trim().toLowerCase();
const filteredProviders = useMemo( () => !needle ? providers : providers.filter( (p) => p.name.toLowerCase().includes(needle) || p.slug.toLowerCase().includes(needle) || (p.models ?? []).some((m) => m.toLowerCase().includes(needle)), ), [providers, needle], );
const filteredModels = useMemo( () => !needle ? models : models.filter((m) => m.toLowerCase().includes(needle)), [models, needle], );
const canConfirm = !!selectedProvider && !!selectedModel;
const confirm = () => { if (!canConfirm) return; const global = persistGlobal ? " --global" : ""; onSubmit( `/model ${selectedModel} --provider ${selectedProvider.slug}${global}`, ); onClose(); };
return ( <div className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4" onClick={(e) => e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true" aria-labelledby="model-picker-title" > <div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col"> <button type="button" onClick={onClose} className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" aria-label="Close" > <X className="h-5 w-5" /> </button>
<header className="p-5 pb-3 border-b border-border"> <h2 id="model-picker-title" className="font-display text-base tracking-wider uppercase" > Switch Model </h2> <p className="text-xs text-muted-foreground mt-1 font-mono"> current: {currentModel || "(unknown)"} {currentProviderSlug && ` · ${currentProviderSlug}`} </p> </header>
<div className="px-5 pt-3 pb-2 border-b border-border"> <div className="relative"> <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Input autoFocus placeholder="Filter providers and models…" value={query} onChange={(e) => setQuery(e.target.value)} className="pl-7 h-8 text-sm" /> </div> </div>
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden"> <ProviderColumn loading={loading} error={error} providers={filteredProviders} total={providers.length} selectedSlug={selectedSlug} query={needle} onSelect={(slug) => { setSelectedSlug(slug); setSelectedModel(""); }} />
<ModelColumn provider={selectedProvider} models={filteredModels} allModels={models} selectedModel={selectedModel} currentModel={currentModel} currentProviderSlug={currentProviderSlug} onSelect={setSelectedModel} onConfirm={(m) => { setSelectedModel(m); // Confirm on next tick so state settles. window.setTimeout(confirm, 0); }} /> </div>
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap"> <label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none"> <input type="checkbox" checked={persistGlobal} onChange={(e) => setPersistGlobal(e.target.checked)} className="cursor-pointer" /> Persist globally (otherwise this session only) </label>
<div className="flex items-center gap-2 ml-auto"> <Button variant="ghost" size="sm" onClick={onClose}> Cancel </Button> <Button size="sm" onClick={confirm} disabled={!canConfirm}> Switch </Button> </div> </footer> </div> </div> );}
/* ------------------------------------------------------------------ *//* Provider column *//* ------------------------------------------------------------------ */
function ProviderColumn({ loading, error, providers, total, selectedSlug, query, onSelect,}: { loading: boolean; error: string | null; providers: ModelOptionProvider[]; total: number; selectedSlug: string; query: string; onSelect(slug: string): void;}) { return ( <div className="border-r border-border overflow-y-auto"> {loading && ( <div className="flex items-center gap-2 p-4 text-xs text-muted-foreground"> <Loader2 className="h-3 w-3 animate-spin" /> loading… </div> )}
{error && <div className="p-4 text-xs text-destructive">{error}</div>}
{!loading && !error && providers.length === 0 && ( <div className="p-4 text-xs text-muted-foreground italic"> {query ? "no matches" : total === 0 ? "no authenticated providers" : "no matches"} </div> )}
{providers.map((p) => { const active = p.slug === selectedSlug; return ( <button key={p.slug} type="button" onClick={() => onSelect(p.slug)} className={`w-full text-left px-3 py-2 text-xs border-l-2 transition-colors cursor-pointer flex items-start gap-2 ${ active ? "bg-primary/10 border-l-primary text-foreground" : "border-l-transparent text-muted-foreground hover:text-foreground hover:bg-muted/40" }`} > <div className="flex-1 min-w-0"> <div className="flex items-center gap-1.5"> <span className="font-medium truncate">{p.name}</span> {p.is_current && <CurrentTag />} </div> <div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate"> {p.slug} · {p.total_models ?? p.models?.length ?? 0} models </div> </div> </button> ); })} </div> );}
/* ------------------------------------------------------------------ *//* Model column *//* ------------------------------------------------------------------ */
function ModelColumn({ provider, models, allModels, selectedModel, currentModel, currentProviderSlug, onSelect, onConfirm,}: { provider: ModelOptionProvider | null; models: string[]; allModels: string[]; selectedModel: string; currentModel: string; currentProviderSlug: string; onSelect(model: string): void; onConfirm(model: string): void;}) { if (!provider) { return ( <div className="overflow-y-auto"> <div className="p-4 text-xs text-muted-foreground italic"> pick a provider → </div> </div> ); }
return ( <div className="overflow-y-auto"> {provider.warning && ( <div className="p-3 text-xs text-destructive border-b border-border"> {provider.warning} </div> )}
{models.length === 0 ? ( <div className="p-4 text-xs text-muted-foreground italic"> {allModels.length ? "no models match your filter" : "no models listed for this provider"} </div> ) : ( models.map((m) => { const active = m === selectedModel; const isCurrent = m === currentModel && provider.slug === currentProviderSlug;
return ( <button key={m} type="button" onClick={() => onSelect(m)} onDoubleClick={() => onConfirm(m)} className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors cursor-pointer flex items-center gap-2 ${ active ? "bg-primary/15 text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted/40" }`} > <Check className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`} /> <span className="flex-1 truncate">{m}</span> {isCurrent && <CurrentTag />} </button> ); }) )} </div> );}
function CurrentTag() { return ( <span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0"> current </span> );}