"use client"; import { useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { CheckCircleIcon } from "@/components/icons"; import { useI18n } from "@/lib/i18n"; import { providerInfo } from "@/lib/providers"; import { springSheet } from "@/lib/motion"; import { useIsPhone } from "@/lib/use-phone"; import type { ChatModelChoice } from "@/lib/chat"; import { cn } from "@/lib/utils"; /** * Model chip picker: a bottom sheet on phones, a small dropdown on desktop. * The list is the server's CHAT_MODELS allowlist (from the health check) with * text/vision badges and availability; picking one remembers it. */ export function ModelPicker({ open, onClose, models, value, valueProvider, onChange, }: { open: boolean; onClose: () => void; models: ChatModelChoice[]; value: string; /** Provider that owns `value` (Ollama when omitted). */ valueProvider?: string; onChange: (model: string, provider?: string) => void; }) { const { t } = useI18n(); const isPhone = useIsPhone(); const reduceMotion = useReducedMotion(); const panel = useRef(null); const [query, setQuery] = useState(""); /* Every model the server lists is offered, so the list can be long — filter it. Cleared each time the picker opens. */ useEffect(() => { if (open) setQuery(""); }, [open]); const needle = query.trim().toLowerCase(); const visible = needle ? models.filter((model) => model.name.toLowerCase().includes(needle)) : models; useEffect(() => { if (!open) return; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); /* Desktop dropdown: a press anywhere outside closes it, except on the trigger chips, which toggle their own popover (see Composer). */ useEffect(() => { if (!open || isPhone) return; const onDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Element)) return; if (panel.current?.contains(target)) return; if (target.closest("[data-picker-trigger]")) return; onClose(); }; document.addEventListener("pointerdown", onDown); return () => document.removeEventListener("pointerdown", onDown); }, [open, isPhone, onClose]); const providerLabel = (id: string) => providerInfo(id)?.label ?? id; const badge = (model: ChatModelChoice) => model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText"); const rows = ( <> {models.length > 8 ? ( setQuery(event.target.value)} placeholder={t("chat.modelSearch")} aria-label={t("chat.modelSearch")} autoComplete="off" spellCheck={false} className="mb-2 w-full rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-sunken)] px-3 py-2 text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)] focus:border-accent-400" /> ) : null} ); if (isPhone) { return ( {open ? (

{t("chat.modelTitle")}

{rows}

{t("chat.modelNote")}

) : null} ); } return ( {open ? (

{t("chat.modelTitle")}

{rows}

{t("chat.modelNote")}

) : null}
); }