feat(ui): search in the agent model dropdown (#352)

Select gains an optional search box that filters its options; the agent model pickers use it, so long model lists can be filtered instead of scrolled. Keyboard navigation and type-ahead work on the filtered list, and the popup keeps its width while filtering.

Co-authored-by: dimassfeb-09 <62858781+dimassfeb-09@users.noreply.github.com>
This commit is contained in:
DIMAS FEBRIYANTO
2026-10-30 13:21:15 +08:00
committed by GitHub
parent 53fd3683e2
commit 97b8928e4f
4 changed files with 45 additions and 4 deletions
+1
View File
@@ -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={
+1
View File
@@ -3431,6 +3431,7 @@
"Chart Settings": "图表设置",
"No numeric values to plot": "没有可绘制的数值",
"Search": "搜索",
"Search…": "搜索…",
"Statements": "语句",
"No statements": "没有语句",
"Statement": "语句",
+13
View File
@@ -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);
}
+30 -4
View File
@@ -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<V extends string = string> {
@@ -26,6 +27,8 @@ export interface SelectProps<V extends string = string> {
"aria-label"?: string;
/** Custom trigger content. */
renderValue?: (option: SelectOption<V> | 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<V extends string = string>({
variant = "framed",
className,
renderValue,
searchable = false,
...aria
}: SelectProps<V>) {
const tx = useTx();
@@ -47,10 +51,16 @@ export function Select<V extends string = string>({
const triggerRef = useRef<HTMLButtonElement>(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<V>) => !!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<V extends string = string>({
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<V extends string = string>({
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<V extends string = string>({
className={styles.popup}
>
<div tabIndex={-1} data-autofocus onKeyDown={onPopupKey} className={styles.popupInner}>
{searchable && (
<div className={styles.searchWrap}>
<SearchField
value={query}
autoFocus
placeholder={tx("Search…")}
onChange={(v) => {
setQuery(v);
setActive(0);
}}
size="normal"
variant="framed"
className={styles.search}
/>
</div>
)}
{/* Virtualised rows are absolutely positioned; this sizes the popup to the widest label. */}
<div aria-hidden className={styles.sizer}>
{options.map((o) => (
@@ -147,7 +173,7 @@ export function Select<V extends string = string>({
))}
</div>
<List
items={options}
items={filtered}
getKey={(o) => o.value}
isSection={isSection}
renderSection={(o) => <span className={styles.disabled}>{tx(o.label)}</span>}