import { useEffect, useRef, useState, type OctaneNode } from "octane"; import { usePreventScroll } from "@octanejs/aria"; import { CaretDown, X } from "@octanejs/phosphor-icons"; import { Badge } from "octane-kumo/components/badge"; import { Button } from "octane-kumo/components/button"; import { CommandPalette } from "octane-kumo/components/command-palette"; import { Dialog } from "octane-kumo/components/dialog"; import { MODEL_CATALOG, MODEL_PROVIDERS, modelLabel, modelPricing, type ModelProvider, type ModelConfiguration, } from "../../shared/model-providers"; const modelChoices = Object.entries(MODEL_CATALOG).flatMap( ([provider, models]) => models.map((model) => ({ value: `${provider}:${model}`, label: modelLabel(provider as ModelProvider, model), pricing: modelPricing(provider as ModelProvider, model), configuration: { provider, model } as ModelConfiguration, })), ); function choiceLabel(value: string) { return value === "default" ? "Installation default" : (modelChoices.find((choice) => choice.value === value)?.label ?? value); } export function ChatModelPicker({ value, disabled, providerReady, onChange, }: { value: ModelConfiguration | null; disabled: boolean; providerReady: (provider: ModelProvider) => boolean; onChange: (value: ModelConfiguration | null) => void; }) { const [open, setOpen] = useState(false); // Overflow alone does not lock the iOS viewport while the keyboard is open. usePreventScroll({ isDisabled: !open }); const [query, setQuery] = useState(""); const [viewport, setViewport] = useState<{ height: number; top: number; } | null>(null); useEffect(() => { if (!open || !window.visualViewport) return; const visible = window.visualViewport; const update = () => setViewport({ height: visible.height, top: visible.offsetTop }); update(); visible.addEventListener("resize", update); visible.addEventListener("scroll", update); return () => { visible.removeEventListener("resize", update); visible.removeEventListener("scroll", update); }; }, [open]); const select = (configuration: ModelConfiguration | null) => { onChange(configuration); setOpen(false); }; return ( { setOpen(next); setQuery(""); }} > } disabled={disabled} aria-label="Model" className="chat-model-trigger chat-composer-select" > {value ? value.model.split("/").at(-1) : "Installation default"}
Choose a model } aria-label="Close model picker" >
choice.value)]} value={query} onValueChange={setQuery} itemToStringValue={choiceLabel} filter={(value, query) => choiceLabel(value).toLowerCase().includes(query.toLowerCase()) } > setOpen(false)} /> select(null)}> Installation default {(Object.keys(MODEL_PROVIDERS) as ModelProvider[]) .filter((provider) => modelChoices.some( (choice) => choice.configuration.provider === provider && choice.label.toLowerCase().includes(query.toLowerCase()), ), ) .map((provider) => ( {MODEL_PROVIDERS[provider].name} {providerReady(provider) ? "" : " · Set up in Settings"} {modelChoices .filter( (choice) => choice.configuration.provider === provider, ) .map((choice) => ( select(choice.configuration)} disabled={!providerReady(provider)} >
{choice.configuration.model} {choice.pricing && ( {choice.pricing} )}
))}
))}
No models found.
); } function ModelResults({ children }: { children: OctaneNode }) { const results = useRef(null); useEffect(() => { const isIOS = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); const list = results.current; if (!isIOS || !list) return; const dismissKeyboard = (event: TouchEvent) => { if (event.touches.length !== 1) return; const dialog = list.closest('[role="dialog"]'); const active = document.activeElement; if (active instanceof HTMLInputElement && dialog?.contains(active)) { // Keep focus inside the modal so its focus trap cannot reopen the keyboard. dialog.focus({ preventScroll: true }); } }; list.addEventListener("touchstart", dismissKeyboard, { passive: true }); return () => list.removeEventListener("touchstart", dismissKeyboard); }, []); return ( {children} ); } function ModelSearchInput({ onClose }: { onClose: () => void }) { const input = useRef(null); useEffect(() => { const field = input.current; const dialog = field?.closest('[role="dialog"]'); if (!field || !dialog) return; // The pinned ARIA adapter loses the active descendant when filtering replaces rows. const syncFocus = () => { const list = dialog.querySelector('[role="listbox"]'); const option = dialog.querySelector( '[role="option"][data-focused="true"]', ); if (list?.id) field.setAttribute("aria-controls", list.id); if (option?.id) field.setAttribute("aria-activedescendant", option.id); else field.removeAttribute("aria-activedescendant"); }; const observer = new MutationObserver(syncFocus); observer.observe(dialog, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-focused"], }); syncFocus(); return () => observer.disconnect(); }, []); return ( { if (event.key === "Escape") { event.preventDefault(); onClose(); return; } if (event.key !== "Enter" || event.isComposing) return; const input = event.target as HTMLInputElement; const option = input .closest('[role="dialog"]') ?.querySelector('[role="option"][data-focused="true"]'); if (!option || option.getAttribute("aria-disabled") === "true") return; event.preventDefault(); option.click(); }} /> ); }