"use client"; import { useCallback, useRef, useState } from "react"; import type { Model } from "@/lib/data"; import { elideMiddle } from "./elide"; // The field is a fixed 420px with 14px of padding either side. It does not // grow with the name: a field that resized would shove the rest of the // sentence around on every keystroke. const TEXT_PX = 420 - 28; const TRACKING = "-1.6px"; // -0.04em at 40px /** * The canvas cannot resolve `var(--font-geist-sans)`, so take the font the * browser actually resolved on the field itself. */ function fontOf(el: HTMLInputElement | null): string | null { if (!el) return null; const s = getComputedStyle(el); return s.font || `${s.fontWeight} ${s.fontSize} ${s.fontFamily}`; } interface Props { onResolve: (model: Model | null) => void; } type Status = | { kind: "empty" } | { kind: "loading" } | { kind: "error"; message: string } | { kind: "ok" }; /** * A free-text field for a Hub repo id. There are millions of models on the * Hub, so there is no list to pick from — the id is typed, and the sizes come * back from /api/model. */ export default function ModelInput({ onResolve }: Props) { const [text, setText] = useState(""); const [status, setStatus] = useState({ kind: "empty" }); const [focused, setFocused] = useState(false); const [font, setFont] = useState(null); // A callback ref runs at commit, which is where the resolved font can be read. const attachInput = useCallback((el: HTMLInputElement | null) => { if (el) setFont(fontOf(el)); }, []); const timer = useRef | undefined>(undefined); // Only the newest lookup may write state; earlier ones lose the race. const latest = useRef(0); async function lookup(id: string, ticket: number) { try { const res = await fetch(`/api/model?id=${encodeURIComponent(id)}`); const body = await res.json(); if (ticket !== latest.current) return; if (!res.ok) { setStatus({ kind: "error", message: body.error ?? "Lookup failed." }); onResolve(null); return; } setStatus({ kind: "ok" }); onResolve(body as Model); } catch { if (ticket !== latest.current) return; setStatus({ kind: "error", message: "Could not reach the Hub." }); onResolve(null); } } function handleChange(next: string) { setText(next); clearTimeout(timer.current); const id = next.trim(); const ticket = ++latest.current; if (!id) { setStatus({ kind: "empty" }); onResolve(null); return; } setStatus({ kind: "loading" }); timer.current = setTimeout(() => lookup(id, ticket), 400); } // While the field is not being edited, a name too long for the box shows its // middle elided so the tail — the part that names the checkpoint — stays // visible. The input keeps the real value throughout. const elided = focused || !font ? text : elideMiddle(text, TEXT_PX, font, TRACKING); const isElided = elided !== text; return ( handleChange(e.target.value)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} placeholder="Qwen/Qwen3-32B" spellCheck={false} autoCapitalize="off" autoComplete="off" title={isElided ? text : undefined} className={`h-[52px] w-[420px] rounded-md bg-paper px-3.5 text-[40px] font-medium leading-[44px] tracking-[-0.04em] ring-raised outline-none placeholder:text-ash ${ isElided ? "text-transparent" : "text-obsidian" }`} /> {isElided && ( {elided} )} {status.kind === "loading" && "Looking up"} {status.kind === "error" && status.message} ); }