import { useEffect, useMemo, useRef, useState } from "react"; import clsx from "clsx"; import { label, scoreInstrument } from "../instruments"; /** * Conditioning instruments: optional pre-selection that overrides the model's * auto-detect. Names come from the backend `/instruments` endpoint; the panel * silently renders empty if the server doesn't expose it. * * Selected instruments are shown as removable tags. Typing in the field offers * a filtered list of suggestions to add. */ export function ConditioningPanel(props: { selected: Set; onChange: (next: Set) => void; }) { const { selected, onChange } = props; const [available, setAvailable] = useState([]); const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const [highlight, setHighlight] = useState(0); const rootRef = useRef(null); useEffect(() => { let cancelled = false; (async () => { try { const resp = await fetch("/instruments"); if (!resp.ok) return; const data = (await resp.json()) as { instruments: string[] }; if (!cancelled) setAvailable(data.instruments); } catch { /* ignore — server may not expose endpoint */ } })(); return () => { cancelled = true; }; }, []); // Close the suggestion list when clicking outside the panel. useEffect(() => { if (!open) return; function onDocClick(e: MouseEvent) { if (!rootRef.current?.contains(e.target as Node)) setOpen(false); } document.addEventListener("mousedown", onDocClick); return () => document.removeEventListener("mousedown", onDocClick); }, [open]); const suggestions = useMemo(() => { return ( available .filter((n) => !selected.has(n)) .map((n, i) => ({ n, i, score: scoreInstrument(n, query) })) .filter((c) => c.score > 0) // Best score first; ties keep the backend ordering for stability. .sort((a, b) => b.score - a.score || a.i - b.i) .map((c) => c.n) ); }, [available, selected, query]); // Keep the highlighted index in range as the suggestion list changes. useEffect(() => { setHighlight((h) => Math.min(h, Math.max(0, suggestions.length - 1))); }, [suggestions.length]); function add(name: string) { if (!available.includes(name) || selected.has(name)) return; const next = new Set(selected); next.add(name); onChange(next); setQuery(""); setHighlight(0); } function remove(name: string) { const next = new Set(selected); next.delete(name); onChange(next); } function onKeyDown(e: React.KeyboardEvent) { if (e.key === "ArrowDown") { e.preventDefault(); setOpen(true); setHighlight((h) => Math.min(h + 1, suggestions.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight((h) => Math.max(h - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); if (open && suggestions[highlight]) add(suggestions[highlight]); } else if (e.key === "Escape") { setOpen(false); } else if (e.key === "Backspace" && query === "" && selected.size > 0) { // Backspace on an empty field removes the most recently added tag. remove(Array.from(selected)[selected.size - 1]); } } return (

What instruments are there in this track?

Optional. Leave empty to let the model detect anything; listing instruments here forbids every other instrument from appearing.

rootRef.current?.querySelector("input")?.focus()} > {Array.from(selected).map((name) => ( {label(name)} ))} { setQuery(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onKeyDown={onKeyDown} />
{open && suggestions.length > 0 && (
    {suggestions.map((name, i) => (
  • setHighlight(i)} onMouseDown={(e) => { // mousedown (not click) so we add before the input blurs. e.preventDefault(); add(name); }} > {label(name)}
  • ))}
)}
); }