diff --git a/src/features/agents/ConfigPickers.tsx b/src/features/agents/ConfigPickers.tsx index bcc562f6..c9d26a00 100644 --- a/src/features/agents/ConfigPickers.tsx +++ b/src/features/agents/ConfigPickers.tsx @@ -44,6 +44,7 @@ export function ConfigPickers({ : undefined, detail: v.description ?? undefined, }))} + searchable={o.category === "model"} onChange={(v) => onChange(o.id, v)} // Effort and other levels say what they are: "Effort Medium". renderValue={ diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index db7eb770..38824211 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3431,6 +3431,7 @@ "Chart Settings": "图表设置", "No numeric values to plot": "没有可绘制的数值", "Search": "搜索", + "Search…": "搜索…", "Statements": "语句", "No statements": "没有语句", "Statement": "语句", diff --git a/src/ui/Select.module.css b/src/ui/Select.module.css index 785f7dcc..a784be74 100644 --- a/src/ui/Select.module.css +++ b/src/ui/Select.module.css @@ -123,3 +123,16 @@ border: 1px solid ButtonText; } } + +.searchWrap { + padding: 4px 6px 6px; +} +.search { + width: 100%; + height: var(--h-field, 28px); + padding: 0 8px; + border-radius: var(--radius-control, 4px); + background: var(--bg-field, var(--bg-button)); + box-shadow: inset 0 0 0 1px var(--border-button); + color: var(--fg-strong); +} diff --git a/src/ui/Select.tsx b/src/ui/Select.tsx index 48a55fb2..d6fb9043 100644 --- a/src/ui/Select.tsx +++ b/src/ui/Select.tsx @@ -4,6 +4,7 @@ import { Icon, type IconName } from "./icons"; import { Popup } from "./Popup"; import { List, listNavigate } from "./List"; import { useTx } from "@/platform/i18n"; +import { SearchField } from "./Input"; import styles from "./Select.module.css"; export interface SelectOption { @@ -26,6 +27,8 @@ export interface SelectProps { "aria-label"?: string; /** Custom trigger content. */ renderValue?: (option: SelectOption | undefined) => ReactNode; + /** Show a search box in the popup (useful for long option lists). */ + searchable?: boolean; } const TYPE_AHEAD_MS = 800; @@ -40,6 +43,7 @@ export function Select({ variant = "framed", className, renderValue, + searchable = false, ...aria }: SelectProps) { const tx = useTx(); @@ -47,10 +51,16 @@ export function Select({ const triggerRef = useRef(null); const current = options.find((o) => o.value === value); const [active, setActive] = useState(0); + const [query, setQuery] = useState(""); const typeAhead = useRef({ text: "", at: -Infinity }); const isSection = (o: SelectOption) => !!o.disabled; + const filtered = + searchable && query.trim() + ? options.filter((o) => !o.disabled && tx(o.label).toLowerCase().includes(query.trim().toLowerCase())) + : options; const openPopup = () => { + setQuery(""); setActive( Math.max( 0, @@ -69,7 +79,7 @@ export function Select({ const onPopupKey = (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - choose(options[active]); + choose(filtered[active]); return; } const next = listNavigate(e.key, { items: options, active, isSection, wrap: true }); @@ -85,10 +95,10 @@ export function Select({ ahead.at = e.timeStamp; const repeat = ahead.text.length > 1 && [...ahead.text].every((c) => c === ahead.text[0]); const prefix = repeat ? ahead.text[0]! : ahead.text; - const n = options.length; + const n = filtered.length; for (let s = prefix.length > 1 ? 0 : 1; s <= n; s++) { const i = (active + s) % n; - if (!options[i]!.disabled && tx(options[i]!.label).toLowerCase().startsWith(prefix)) { + if (!filtered[i]!.disabled && tx(filtered[i]!.label).toLowerCase().startsWith(prefix)) { setActive(i); break; } @@ -135,6 +145,22 @@ export function Select({ className={styles.popup} >
+ {searchable && ( +
+ { + setQuery(v); + setActive(0); + }} + size="normal" + variant="framed" + className={styles.search} + /> +
+ )} {/* Virtualised rows are absolutely positioned; this sizes the popup to the widest label. */}
{options.map((o) => ( @@ -147,7 +173,7 @@ export function Select({ ))}
o.value} isSection={isSection} renderSection={(o) => {tx(o.label)}}